Compare commits
10 Commits
6e05a366c9
...
b64d1aba5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b64d1aba5b | ||
|
|
cb230fe50e | ||
|
|
dbab96f7fd | ||
|
|
d34f25d544 | ||
|
|
1be56c1f60 | ||
|
|
a843f300a7 | ||
|
|
74849a8e2c | ||
|
|
693f9513f1 | ||
|
|
2faa91de9b | ||
|
|
75345ebc03 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ npm-debug.log*
|
|||||||
*.swp
|
*.swp
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
Новости/
|
||||||
|
|||||||
10
00. НАЧНИ ОТСЮДА.md
Normal file
10
00. НАЧНИ ОТСЮДА.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 00. НАЧНИ ОТСЮДА — amnezia_web-PRO
|
||||||
|
|
||||||
|
**Промпт для нового чата:**
|
||||||
|
> Проект «Открытые проекты/amnezia_web-PRO» — полностью открытая веб-панель Amnezia (Node, `server.js` + `public/`). Закрытой версии нет (старая — в `Архив/amnezia_web-PRO_test`). Прочитай `PROJECT_INDEX.md` и `09. История и грабли`. Тесты — на school-friends (сервер) и school-it-kruzhok (клиент) через svault. Релиз: тег + GitHub API, затем Gitea, Notes, новость в группу PCA, тема «Новости» (chat_id -1003902709196, message_thread_id=131) ботом @PCAdmin27_bot (токен `TELEGRAM_MOD_BOT_TOKEN` в «Закрытые продукты/home_Friends_It/ДОСТУПЫ.md»); PCA_jarvis_bot не использовать. Gitea — pull-зеркало, обновлять mirror-sync.
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
- `PROJECT_INDEX.md` — что где лежит, установка
|
||||||
|
- `09. История и грабли` — оглавление записей 09.N
|
||||||
|
- `scripts/checks/` — проверки парка AWG (запускать внутри контейнера панели, читают /app/data/data.json)
|
||||||
|
- `Новости/` — тексты новостей релизов (не в git)
|
||||||
6
09. История и грабли.md
Normal file
6
09. История и грабли.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 09. История и грабли
|
||||||
|
|
||||||
|
- [09.1 Версия 1.3.0 — протоколы с сайта, каскад, домен, тема](<09.1 Версия 1.3.0 — протоколы с сайта, каскад, домен, тема.md>) — 23.10.2026
|
||||||
|
- [09.2 Версия 1.4.0 — Xray VLESS, всё открыто](<09.2 Версия 1.4.0 — Xray VLESS, всё открыто.md>) — 23.10.2026
|
||||||
|
- [09.3 Старые исправления июня 2026](<09.3 Старые исправления июня 2026.md>) — 13 частей 09.3.N, перенесены из Notes 23.10.2026
|
||||||
|
- 04.10.2026 — проект переехал в папку-группу; код не менялся.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 09.1 Версия 1.3.0 — протоколы с сайта, каскад, домен, тема (23.10.2026)
|
||||||
|
|
||||||
|
СИМПТОМ: (а) клиент AWG 1.x из панели — рукопожатие есть, интернета нет; (б) после обновления переключение инстанса создавало клиента не в том контейнере; (в) проверка занятого порта ничего не ловила.
|
||||||
|
ПРИЧИНА: (а) выгрузка .conf подставляла «умолчательные» S3/S4/I1, которых нет на сервере 1.x; (б) install.sh клал контейнеры панели в AWG_PROFILES, env-копия затеняла управляемый инстанс; (в) `ss` запускался внутри контейнера панели.
|
||||||
|
КАК ПРОВЕРИТЬ ЗА МИНУТУ:
|
||||||
|
curl -s http://IP:8080/health # version 1.3.0
|
||||||
|
в .conf клиента 1.x не должно быть строк S3/S4; GET /api/protocols — id управляемых = amnezia-<вариант>-<порт>
|
||||||
|
ИСПРАВЛЕНИЕ: v1.3.0 (тег v1.3.0). Параметры обфускации берутся с сервера (awgObfuscationLines); getProfiles отдаёт приоритет управляемым; порт — через docker ps. Добавлено: AWG 3.0/3.1 (образы по дайджесту, патч awg-quick на userspace), домен в /data/settings.json, каскад POST /api/cascade/upstream (конфиг другого сервера → интерфейс warp), светлая тема.
|
||||||
|
Проверено на школе (friends = сервер, it-kruzhok = клиент/выход): Legacy, 1.x, 2.0, 3.0, 3.1 — рукопожатие + интернет; каскад — трафик прошёл через второй сервер, переживает рестарт; домен; обновление поверх контейнера приложения amnezia-awg; чистая установка с GitHub. Тестовое удалено.
|
||||||
|
ПРОФИЛАКТИКА: перед релизом гонять клиентов всех вариантов со второй машины (скрипт-клиент в контейнере amneziawg-go 3.1). Xray/VLESS в открытую панель не вошёл — есть в закрытом amnezia_web-PRO_test.
|
||||||
|
Не сделано: Gitea (.153:3030 недоступна из сети мини 23.10) — допушить `git push gitea main --tags`.
|
||||||
|
|
||||||
|
- 23.10.2026: новость о 1.3.0 опубликована в @PCAdministration (сообщение 877). Gitea не отправлено: Proxmox 192.168.88.5 и ВМ .153 не в сети — после включения `git push gitea main --tags`.
|
||||||
33
09.2 Версия 1.4.0 — Xray VLESS, всё открыто.md
Normal file
33
09.2 Версия 1.4.0 — Xray VLESS, всё открыто.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 09.2 Версия 1.4.0 — Xray VLESS-Reality, всё открыто (23.10.2026)
|
||||||
|
|
||||||
|
**Задача.** Перенести Xray/VLESS из закрытой панели, закрытую версию не развивать, убрать всё «платное».
|
||||||
|
|
||||||
|
**Сделано.**
|
||||||
|
- Убрана редакция community («только просмотр», баннер «купите PRO», `requireProTier`, `AMNEZIA_EDITION`, `COMMUNITY_UPGRADE_*`). Все функции открыты.
|
||||||
|
- Xray VLESS-Reality в `server.js` (блок «Xray VLESS-Reality») и раздел на странице: установка (TCP-порт, сайт-маскировка), клиенты создать/выкл/вкл/удалить, ссылка `vless://`.
|
||||||
|
Образ собирается на сервере (alpine + Xray v26.3.27), контейнер `amnezia-xray`, файлы `/opt/amnezia/xray` в формате приложения Amnezia (`xray_*.key`, `clientsTable`). Выключенный клиент — убран из `server.json`, помечен `disabled` в `clientsTable`.
|
||||||
|
- Закрытая панель `amnezia_web-PRO_test` перенесена в `Архив/` (SSD и Notes), там файл «АРХИВ — почему».
|
||||||
|
|
||||||
|
**Проверено** на school-friends (сервер) + school-it-kruzhok (клиент xray-core): соединение есть (HTTP 200 через socks), выключение клиента рвёт связь, включение возвращает. Тестовое удалено.
|
||||||
|
|
||||||
|
## Грабли
|
||||||
|
- **СИМПТОМ:** установка Xray «Не удалось получить ключи x25519». **ПРИЧИНА:** новый Xray пишет `Password (PublicKey): …` вместо `Public key:`. **ИСПРАВЛЕНИЕ:** регэксп `(?:Public\s*key|Password)[^:\n]*:`. **ПРОВЕРИТЬ:** `docker run --rm --entrypoint /usr/bin/xray amnezia-xray x25519`.
|
||||||
|
- **СИМПТОМ:** форма установки видна при установленном Xray. **ПРИЧИНА:** CSS `.instance-form` перебивает атрибут `hidden`. **ИСПРАВЛЕНИЕ:** `style.display`.
|
||||||
|
- Оба школьных сервера выходят в интернет через один NAT — проверка «по IP» не отличает туннель; проверять выключением клиента.
|
||||||
|
|
||||||
|
## 1.4.1 (23.10.2026)
|
||||||
|
- Установщик сам ставит Docker (`get.docker.com`) на чистом сервере. На машине без Docker не проверялось — школьные все с Docker.
|
||||||
|
- README: раздел «Быстрый старт» — 7 шагов от пустого VPS (порты, вход admin, протокол, клиент, Xray, домен, «если не работает»).
|
||||||
|
- Убрана подсказка «задайте AWG_PROFILES» — сбивала новичков.
|
||||||
|
- Проверено как у человека с GitHub: чистая установка `curl … | sudo bash` → вход admin → AWG 2.0 + клиент (рукопожатие, интернет) → Xray + клиент → обновление поверх (версия 1.4.1, клиенты на месте). Тестовое удалено.
|
||||||
|
|
||||||
|
## Проверка установщика на чистом сервере (23.10.2026)
|
||||||
|
- LXC 199 на Proxmox .5 (Ubuntu 24.04, unprivileged, nesting+keyctl), без Docker → `curl -fsSL …/scripts/install.sh | bash` → Docker поставился сам, контейнеры `amnezia-admin` и `amnezia-web-landing` поднялись, :8080 = 200, `/api/login` с паролем admin = ok. Ошибок нет, 1.4.2 не понадобилась. LXC удалён.
|
||||||
|
- Proxmox .5 и Gitea .153:3030 из сессии отвечают напрямую; в Gitea тег v1.4.1 есть.
|
||||||
|
|
||||||
|
## Публикация
|
||||||
|
- Новость 1.4.0 — @PCAdmin27_bot, тема «Новости» (131), сообщение 881. PCA_jarvis_bot из группы выгнан.
|
||||||
|
- Gitea — pull-зеркало: обновляется `mirror-sync` (push → 403). С мини .153 «недоступна» только из сессии Claude — ходить через `ssh andrey@127.0.0.1`.
|
||||||
|
|
||||||
|
## Осталось
|
||||||
|
- QR-кода для vless нет (ссылка копируется) — добавить, если попросят.
|
||||||
17
09.3 Старые исправления июня 2026.md
Normal file
17
09.3 Старые исправления июня 2026.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 09.3 Старые исправления июня 2026
|
||||||
|
|
||||||
|
Оглавление частей: заметки из Notes до регламента (июнь–июль 2026), по одной на исправление.
|
||||||
|
|
||||||
|
- [09.3.1 Canonical public amnezia_web-PRO 06.06](<09.3.1 Canonical public amnezia_web-PRO 06.06.md>)
|
||||||
|
- [09.3.2 Canonical amnezia_web-PRO status 21.06](<09.3.2 Canonical amnezia_web-PRO status 21.06.md>)
|
||||||
|
- [09.3.3 Fix AWG auto profiles 21.06](<09.3.3 Fix AWG auto profiles 21.06.md>)
|
||||||
|
- [09.3.4 Fix stale AWG profile snapshot 21.06](<09.3.4 Fix stale AWG profile snapshot 21.06.md>)
|
||||||
|
- [09.3.5 Client import from Amnezia backup 21.06](<09.3.5 Client import from Amnezia backup 21.06.md>)
|
||||||
|
- [09.3.6 Fix duplicate AWG instances 21.06](<09.3.6 Fix duplicate AWG instances 21.06.md>)
|
||||||
|
- [09.3.7 Fix AWG instance labels 21.06](<09.3.7 Fix AWG instance labels 21.06.md>)
|
||||||
|
- [09.3.8 Fix clients selected profile 21.06](<09.3.8 Fix clients selected profile 21.06.md>)
|
||||||
|
- [09.3.9 Fix peers missing clientsTable 21.06](<09.3.9 Fix peers missing clientsTable 21.06.md>)
|
||||||
|
- [09.3.10 Sync app-created Amnezia users 21.06](<09.3.10 Sync app-created Amnezia users 21.06.md>)
|
||||||
|
- [09.3.11 Fix legacy clientsTable formats 21.06](<09.3.11 Fix legacy clientsTable formats 21.06.md>)
|
||||||
|
- [09.3.12 Fix Amnezia source auto-discovery 22.06](<09.3.12 Fix Amnezia source auto-discovery 22.06.md>)
|
||||||
|
- [09.3.13 Fix awg binary not found 22.06](<09.3.13 Fix awg binary not found 22.06.md>)
|
||||||
17
09.3.1 Canonical public amnezia_web-PRO 06.06.md
Normal file
17
09.3.1 Canonical public amnezia_web-PRO 06.06.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 09.3.1 Canonical public amnezia_web-PRO 06.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
amnezia_web-PRO canonical public repo — 06.06
|
||||||
|
Keep one public project: https://github.com/andrey271192/amnezia_web-PRO
|
||||||
|
Install
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
||||||
|
Defaults
|
||||||
|
Fresh login: admin
|
||||||
|
Fresh password: admin
|
||||||
|
Change password after first login.
|
||||||
|
GitHub
|
||||||
|
Repo is public.
|
||||||
|
Private download flow removed.
|
||||||
|
Separate deploy bundle removed from active docs and notes.
|
||||||
|
Commit: 05e6d00 docs: make pro repo public install source.
|
||||||
23
09.3.10 Sync app-created Amnezia users 21.06.md
Normal file
23
09.3.10 Sync app-created Amnezia users 21.06.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 09.3.10 Sync app-created Amnezia users 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: dfeabe0 feat: sync app-created peers into clients table
|
||||||
|
Version: v1.2.36
|
||||||
|
Задача
|
||||||
|
Клиенты уже были созданы в приложении Amnezia. Их нужно подтянуть в веб-панель, а не создавать заново.
|
||||||
|
Решение
|
||||||
|
Добавлена кнопка Пользователи → Подтянуть из awg0.conf.
|
||||||
|
POST /api/clients/sync-peers берёт активные [Peer] текущего инстанса и создаёт недостающие строки clientsTable.
|
||||||
|
VPN-конфиг не пересоздаётся, контейнеры клиентов не трогаются.
|
||||||
|
Имена создаются как Клиент IP или Peer KEY, потом можно переименовать.
|
||||||
|
Экспорт .conf появится после импорта клиентского .conf, потому приватный ключ клиента есть только в приложении.
|
||||||
|
Команда
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Потом Ctrl+F5 → выбрать Инстанс → Пользователи → Подтянуть из awg0.conf.
|
||||||
|
Проверено
|
||||||
|
node --check server.js
|
||||||
|
node --check public/app.js
|
||||||
|
bash -n scripts/*.sh
|
||||||
|
npm audit --audit-level=moderate: 0 vulnerabilities
|
||||||
|
git diff --check
|
||||||
17
09.3.11 Fix legacy clientsTable formats 21.06.md
Normal file
17
09.3.11 Fix legacy clientsTable formats 21.06.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 09.3.11 Fix legacy clientsTable formats 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: 4e6ab2d fix: parse legacy clients table formats
|
||||||
|
Version: v1.2.37
|
||||||
|
Причина
|
||||||
|
Старые клиенты могли не подтягиваться, если clientsTable не был массивом [{clientId,userData}], а имел object/map/nested формат другой версии Amnezia.
|
||||||
|
Исправлено
|
||||||
|
parseClientsTable поддерживает array, object map, nested clients/users/rows/items/data/clientList/clientsTable.
|
||||||
|
clientRowId понимает clientId/id/publicKey/public_key/key/client_id и вложенные userData поля.
|
||||||
|
Добавлен GET /api/clients/source-report для диагностики источников без полных ключей.
|
||||||
|
Команда
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Потом Ctrl+F5 → выбрать Инстанс → Пользователи → Подтянуть из awg0.conf.
|
||||||
|
Если всё равно пусто
|
||||||
|
Открыть в браузере после логина: /api/clients/source-report?profileId=awg и /api/clients/source-report?profileId=awg2
|
||||||
20
09.3.12 Fix Amnezia source auto-discovery 22.06.md
Normal file
20
09.3.12 Fix Amnezia source auto-discovery 22.06.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 09.3.12 Fix Amnezia source auto-discovery 22.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: b360095 fix: auto-discover amnezia client sources
|
||||||
|
Version: v1.2.38
|
||||||
|
Root cause
|
||||||
|
Amnezia installs can keep old clients in /opt/amnezia/awg/wg0.conf, while profile pointed to /opt/amnezia/awg/awg0.conf. clientsTable can also live in /opt/amnezia/wireguard/clientsTable or have non-array format.
|
||||||
|
Fix
|
||||||
|
Runtime now scans config candidates: awg0.conf, wg0.conf, wireguard/wg0.conf.
|
||||||
|
Chooses config with max [Peer] count.
|
||||||
|
Runtime scans clientsTable candidates: awg/clientsTable and wireguard/clientsTable.
|
||||||
|
Chooses clientsTable with max parsed client count.
|
||||||
|
iface is inferred from selected config filename.
|
||||||
|
source-report shows resolved confPath/clientsPath/iface.
|
||||||
|
Command
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Then Ctrl+F5 → select Instance → Users → Pull from awg0.conf.
|
||||||
|
Source
|
||||||
|
Amnezia issue #1615 mentions manual flow with /opt/amnezia/awg/wg0.conf and clientsTable; issue #2238 confirms clientsTable field variance.
|
||||||
15
09.3.13 Fix awg binary not found 22.06.md
Normal file
15
09.3.13 Fix awg binary not found 22.06.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 09.3.13 Fix awg binary not found 22.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: 8e5d51a fix: auto-detect wg binary in containers
|
||||||
|
Version: v1.2.39
|
||||||
|
Symptom
|
||||||
|
При добавлении/подтягивании старых .conf ошибка: sh: awg: not found. В amneziawg контейнере awg binary отсутствует.
|
||||||
|
Fix
|
||||||
|
Runtime auto-detects binary inside container: preferred wgBinary, then wg for wg0.conf, then awg, then wg.
|
||||||
|
show/pubkey/genkey/syncconf use resolved binary, not hardcoded profile.wgBinary.
|
||||||
|
source-report and /api/clients expose resolved wgBinary.
|
||||||
|
Command
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Then Ctrl+F5 → Instance → Users → Pull from awg0.conf.
|
||||||
20
09.3.2 Canonical amnezia_web-PRO status 21.06.md
Normal file
20
09.3.2 Canonical amnezia_web-PRO status 21.06.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 09.3.2 Canonical amnezia_web-PRO status 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Важно: amnezia_web-PRO_test — отдельный продукт/тестовая ветка. Canonical public product сейчас: amnezia_web-PRO.
|
||||||
|
GitHub: https://github.com/andrey271192/amnezia_web-PRO
|
||||||
|
Local: /Users/andrejbobyrev/andrey271192-repos/amnezia_web-PRO
|
||||||
|
Last pushed commit: c202f0b — chore: add npm lockfile
|
||||||
|
Правило
|
||||||
|
amnezia_web-PRO — основной публичный продукт.
|
||||||
|
amnezia_web-PRO_test — отдельный тестовый продукт, не смешивать с public PRO.
|
||||||
|
amnezia-web-pro-deploy — legacy/старый deploy repo, не использовать как основной продукт.
|
||||||
|
Install
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
||||||
|
Defaults
|
||||||
|
Fresh login: admin
|
||||||
|
Fresh password: admin
|
||||||
|
Change password after first login.
|
||||||
|
AWG issue from client
|
||||||
|
Install-check видит два контейнера: amnezia-awg2 и amnezia-awg. Если веб показывает только awg2, причина в runtime-discovery/cache/UI-filter, не в Docker. Надо проверить конкретный VPS: docker ps, panel API/status, logs, затем исправить отображение обоих AWG profiles без ручного AWG_PROFILES.
|
||||||
22
09.3.3 Fix AWG auto profiles 21.06.md
Normal file
22
09.3.3 Fix AWG auto profiles 21.06.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 09.3.3 Fix AWG auto profiles 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Repo: https://github.com/andrey271192/amnezia_web-PRO
|
||||||
|
Commit: cbc20a1 — fix: auto-detect awg profiles on install
|
||||||
|
Что исправлено
|
||||||
|
Переустановка из GitHub работает для /opt/amnezia-admin без .git.
|
||||||
|
Установщик сохраняет текущий порт панели.
|
||||||
|
Установщик сохраняет текущий Docker volume /data.
|
||||||
|
Перед заменой /opt/amnezia-admin создаётся backup папки.
|
||||||
|
Если найден один контейнер amnezia-awg*, он становится AWG_CONTAINER.
|
||||||
|
Если найдено 2+ контейнера amnezia-awg*, AWG_PROFILES создаётся автоматически и сохраняется в /root/amnezia-admin.awg-profiles.json.
|
||||||
|
Dockerfile использует npm ci по package-lock.json.
|
||||||
|
Что сказать пользователю
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo env SKIP_LANDING=1 bash
|
||||||
|
После установки: обновить страницу Ctrl+F5. AWG контейнеры не удаляются и не пересоздаются.
|
||||||
|
Проверено
|
||||||
|
bash -n scripts/install.sh
|
||||||
|
bash -n scripts/uninstall.sh
|
||||||
|
node --check server.js
|
||||||
|
npm audit --audit-level=moderate
|
||||||
9
09.3.4 Fix stale AWG profile snapshot 21.06.md
Normal file
9
09.3.4 Fix stale AWG profile snapshot 21.06.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 09.3.4 Fix stale AWG profile snapshot 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: 8ba2a26 — fix: refresh stale awg profile snapshots
|
||||||
|
Причина повтора бага: старый /root/amnezia-admin.awg-profiles.json мог содержать один профиль. Installer восстанавливал его и не пересобирал profiles по двум контейнерам.
|
||||||
|
Теперь если контейнеров amnezia-awg* два или больше, а восстановленный AWG_PROFILES содержит меньше двух container, installer автоматически пересобирает profiles и перезаписывает snapshot.
|
||||||
|
rm -f /root/amnezia-admin.awg-profiles.json
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s) | sudo env SKIP_LANDING=1 bash
|
||||||
24
09.3.5 Client import from Amnezia backup 21.06.md
Normal file
24
09.3.5 Client import from Amnezia backup 21.06.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 09.3.5 Client import from Amnezia backup 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Project: amnezia_web-PRO
|
||||||
|
Repo: https://github.com/andrey271192/amnezia_web-PRO
|
||||||
|
Commit: cff27e2 feat: import clients from amnezia configs
|
||||||
|
Что добавлено
|
||||||
|
В панели появился блок Импорт клиента.
|
||||||
|
Можно вставить готовый клиентский .conf или JSON backup Amnezia, если внутри есть текстовый .conf.
|
||||||
|
Если peer уже есть в awg0.conf, дубль не создаётся: восстанавливается/обновляется строка clientsTable.
|
||||||
|
Если peer отсутствует, он добавляется в текущий выбранный Инстанс.
|
||||||
|
Исходный .conf сохраняется в userData.last_config, чтобы дальше работали экспорт и управление из веба.
|
||||||
|
Добавлена защита: импортируемый .conf должен относиться к текущему серверу/инстансу по PublicKey сервера.
|
||||||
|
Команда переустановки для пользователя
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Проверено
|
||||||
|
node --check server.js
|
||||||
|
node --check public/app.js
|
||||||
|
bash -n для всех scripts/*.sh
|
||||||
|
npm audit --audit-level=moderate: 0 vulnerabilities
|
||||||
|
git diff --check
|
||||||
|
Что сказать пользователю
|
||||||
|
Обновите панель командой выше, зайдите в веб, выберите нужный Инстанс и откройте блок Импорт клиента. Вставьте экспортированный клиентский .conf из приложения Amnezia или backup, где внутри есть этот .conf. После импорта клиент появится в таблице, а старые peer-дубли создаваться не будут.
|
||||||
22
09.3.6 Fix duplicate AWG instances 21.06.md
Normal file
22
09.3.6 Fix duplicate AWG instances 21.06.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 09.3.6 Fix duplicate AWG instances 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Project: amnezia_web-PRO
|
||||||
|
Commit: 8c67d7d fix: dedupe duplicate awg instances
|
||||||
|
Version: v1.2.32
|
||||||
|
Причина
|
||||||
|
После переустановки один и тот же AWG контейнер мог попасть в список дважды: из AWG_PROFILES и из saved managed instances. Старый dedup был только по id, поэтому при разных id веб показывал две одинаковые карточки.
|
||||||
|
Исправлено
|
||||||
|
Dedup профилей по container + confPath + iface + clientsPath.
|
||||||
|
/api/instances дополнительно дедупит runtime target после auto-resolve контейнера.
|
||||||
|
load/save managed profiles чистит дубли из instances.json.
|
||||||
|
Docs обновлены: раздел Инстансы задублировались.
|
||||||
|
Команда для пользователя
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
После установки: Ctrl+F5 в браузере.
|
||||||
|
Проверено
|
||||||
|
node --check server.js
|
||||||
|
node --check public/app.js
|
||||||
|
npm audit --audit-level=moderate: 0 vulnerabilities
|
||||||
|
git diff --check
|
||||||
15
09.3.7 Fix AWG instance labels 21.06.md
Normal file
15
09.3.7 Fix AWG instance labels 21.06.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 09.3.7 Fix AWG instance labels 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: a9a67af fix: show distinct awg instance labels
|
||||||
|
Version: v1.2.33
|
||||||
|
Причина
|
||||||
|
У пользователя реально два контейнера: amnezia-awg2 и amnezia-awg. Порты и число клиентов разные, значит это не дубль. Но UI рисовал title по variant, поэтому обе карточки выглядели как AmneziaWG 2.0.
|
||||||
|
Исправлено
|
||||||
|
Карточка инстанса показывает profile label, а не только variant title.
|
||||||
|
Backend лучше определяет variant по profile/container/label до чтения конфига.
|
||||||
|
Docs уточнены: два разных контейнера с разными портами — нормальные два инстанса.
|
||||||
|
Команда
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Потом Ctrl+F5.
|
||||||
18
09.3.8 Fix clients selected profile 21.06.md
Normal file
18
09.3.8 Fix clients selected profile 21.06.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 09.3.8 Fix clients selected profile 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: cb5fca3 fix: load clients for selected profile
|
||||||
|
Version: v1.2.34
|
||||||
|
Симптом
|
||||||
|
Переключатель Инстанс показывает правильный контейнер, но таблица Пользователи показывает клиентов от другого/старого инстанса.
|
||||||
|
Причина
|
||||||
|
Создание/экспорт уже передавали profileId явно, но /api/clients и операции таблицы опирались на cookie выбранного профиля. Cookie мог не примениться/устареть, поэтому список клиентов приходил не от выбранного инстанса.
|
||||||
|
Исправлено
|
||||||
|
GET /api/clients принимает явный profileId.
|
||||||
|
disable/enable/rename/delete/disconnect-date используют runtimeFromExportRequest и body.profileId.
|
||||||
|
Frontend передаёт profileId во все запросы таблицы клиентов.
|
||||||
|
Docs обновлены.
|
||||||
|
Команда
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Потом Ctrl+F5.
|
||||||
18
09.3.9 Fix peers missing clientsTable 21.06.md
Normal file
18
09.3.9 Fix peers missing clientsTable 21.06.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 09.3.9 Fix peers missing clientsTable 21.06
|
||||||
|
|
||||||
|
_Старая заметка из Notes (июнь 2026), перенесена 23.10.2026. Оригинал: Архив/Старые заметки Notes 20261023._
|
||||||
|
|
||||||
|
Commit: fd66b0a fix: show peers missing clients table
|
||||||
|
Version: v1.2.35
|
||||||
|
Симптом
|
||||||
|
Инстанс выбран правильно, но старые клиенты не появляются в таблице Пользователи.
|
||||||
|
Причина
|
||||||
|
UI строил список только из clientsTable. У старых/созданных приложением клиентов peer может быть в awg0.conf, а запись clientsTable отсутствовать или иметь другой формат.
|
||||||
|
Исправлено
|
||||||
|
GET /api/clients теперь возвращает union clientsTable + Peer из awg0.conf.
|
||||||
|
Peer-only строки показываются как Peer xxxx… и помечаются missingClientTable.
|
||||||
|
Rename/date/disable/delete умеют работать с peer-only строками.
|
||||||
|
Для экспорта .conf всё равно нужен импорт клиентского .conf, потому приватный ключ клиента сервер не знает.
|
||||||
|
Команда
|
||||||
|
curl -fsSL "https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh?nocache=$(date +%s)" | sudo env SKIP_LANDING=1 bash
|
||||||
|
Потом Ctrl+F5.
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
| Что | Где |
|
| Что | Где |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Папка проекта | `/Volumes/ssd/Мои проекты/amnezia_web-PRO/` |
|
| Папка проекта | `/Volumes/ssd/Мои проекты/Открытые проекты/amnezia_web-PRO/` |
|
||||||
| Заметки | Apple Notes → `МОИ ПРОЕКТЫ` → папка `amnezia_web-PRO` |
|
| Заметки | Apple Notes → `МОИ ПРОЕКТЫ` → папка `amnezia_web-PRO` |
|
||||||
| GitHub (публичный) | https://github.com/andrey271192/amnezia_web-PRO |
|
| GitHub (публичный) | https://git.bobyrev.com/PCA/amnezia_web-PRO |
|
||||||
| Ветка прода | `main` |
|
| Ветка прода | `main` |
|
||||||
| Тестовая копия рядом | `/Volumes/ssd/Мои проекты/amnezia_web-PRO_test/` |
|
| Старая закрытая панель (архив, не развивается) | `/Volumes/ssd/Мои проекты/Архив/amnezia_web-PRO_test/` |
|
||||||
|
|
||||||
Путь `/Volumes/andrey/Мои проекты/...` из старого промпта синхронизации **не существует** —
|
Путь `/Volumes/andrey/Мои проекты/...` из старого промпта синхронизации **не существует** —
|
||||||
актуальная копия только на `/Volumes/ssd`.
|
актуальная копия только на `/Volumes/ssd`.
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Первый вход: `admin` / `admin`, пароль сменить сразу.
|
Первый вход: `admin` / `admin`, пароль сменить сразу.
|
||||||
@@ -65,4 +65,4 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s
|
|||||||
|
|
||||||
## Состояние
|
## Состояние
|
||||||
|
|
||||||
- HEAD `7b92bfb` (08.09.2026), совпадает с `origin/main`.
|
- v1.3.0 (23.10.2026), тег и релиз на GitHub. Gitea — допушить (была недоступна).
|
||||||
|
|||||||
123
README.md
123
README.md
@@ -1,10 +1,95 @@
|
|||||||
# amnezia_web-PRO
|
# amnezia_web-PRO
|
||||||
|
|
||||||
## Public PRO panel
|
Полностью открытая веб-панель для сервера Amnezia: все функции бесплатны, без платных или закрытых редакций. Один репозиторий, один установщик.
|
||||||
|
|
||||||
**amnezia_web-PRO** — public full Amnezia Web panel. One repository, one installer, no private download flow and no separate deploy repository.
|
<img src="docs/screenshots/panel-1.4.png" alt="Панель amnezia_web-PRO 1.4: пользователи AmneziaWG, Xray VLESS-Reality, протоколы и инстансы" width="780"/>
|
||||||
|
|
||||||
Base project **[amnezia_web](https://github.com/andrey271192/amnezia_web)** remains a lighter open panel. This repository contains the full PRO feature set by default.
|
**Что умеет:** клиенты AmneziaWG (создать, выключить, дата отключения, .conf), установка протоколов из браузера (AmneziaWG 1.x/2.0/3.0/3.1, WireGuard), Xray VLESS‑Reality, Cloudflare WARP и каскад через другой сервер, Telegram MTProto‑прокси, домен сервера в конфигах, светлая/тёмная тема.
|
||||||
|
|
||||||
|
**Установка** (Ubuntu 22.04/24.04, Debian 12, под root; Docker поставится сам):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Панель: `http://IP:8080`, вход — пароль `admin` (смените сразу). Код — [git.bobyrev.com/PCA/amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO) (GitHub-аккаунт заблокирован, старые ссылки raw.githubusercontent.com больше не работают).
|
||||||
|
|
||||||
|
## Что нового в 1.4.2
|
||||||
|
|
||||||
|
- **Проект переехал на [git.bobyrev.com/PCA/amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO)** — GitHub-аккаунт заблокирован. Установщик и обновление качают код оттуда. Старая команда с `raw.githubusercontent.com` больше не работает — используйте новую (выше).
|
||||||
|
|
||||||
|
## Что нового в 1.4.0
|
||||||
|
|
||||||
|
- **Xray VLESS‑Reality** — раздел «Xray VLESS‑Reality»: установка с выбором TCP‑порта и сайта‑маскировки, клиенты (создать, выключить/включить, удалить), ссылка `vless://` для Amnezia VPN, v2rayNG, Hiddify, Streisand, Happ. Файлы в `/opt/amnezia/xray` в том же формате, что у приложения Amnezia.
|
||||||
|
- **Установщик сам ставит Docker** на чистом сервере.
|
||||||
|
- **Всё открыто:** убрана редакция «community / только просмотр» и переменные `AMNEZIA_EDITION`, `COMMUNITY_UPGRADE_*` — все функции доступны всем.
|
||||||
|
|
||||||
|
## Что нового в 1.3.0
|
||||||
|
|
||||||
|
- **Установка протоколов прямо с сайта** (раздел «Протоколы / инстансы»), несколько версий рядом на разных портах:
|
||||||
|
| Вариант | Для кого |
|
||||||
|
|---|---|
|
||||||
|
| AmneziaWG 3.1 | новейший: защита заголовков, случайные хвосты; свежее приложение Amnezia |
|
||||||
|
| AmneziaWG 3.0 | HeaderProtectionKey, паддинг |
|
||||||
|
| AmneziaWG 2.0 | рекомендуемый по умолчанию (S3/S4) |
|
||||||
|
| AmneziaWG 1.x | роутеры Keenetic/OpenWrt и старые приложения (без S3/S4) |
|
||||||
|
| WireGuard (Legacy) | любой WG-клиент, без обфускации |
|
||||||
|
- **Исправлено:** конфиг клиента для AWG 1.x получал лишние S3/S4/I1 — рукопожатие было, интернета нет. Теперь в .conf попадают только параметры, которые есть на сервере.
|
||||||
|
- **Исправлено:** при обновлении установщик записывал контейнеры панели в `AWG_PROFILES`, и переключение инстанса создавало клиента не там.
|
||||||
|
- **Исправлено:** проверка занятого UDP-порта смотрела внутрь контейнера панели и ничего не находила.
|
||||||
|
- **Домен сервера** — поле «Домен или IP сервера в конфигах клиентов»: подставляется в Endpoint всех выгрузок, домен проверяется на резолв.
|
||||||
|
- **Настоящий каскад** — в разделе «Cloudflare WARP / каскад» вставьте клиентский .conf другого сервера: отмеченные клиенты выходят в интернет через него (переживает перезапуск контейнера).
|
||||||
|
- **Светлая / тёмная тема** — кнопка «◐ Тема» в шапке (по умолчанию как в системе).
|
||||||
|
- Старые установки не затрагиваются: контейнеры, поставленные приложением Amnezia, остаются как были.
|
||||||
|
|
||||||
|
## Быстрый старт: от пустого сервера до VPN за 10 минут
|
||||||
|
|
||||||
|
**Что нужно:** VPS с Ubuntu 22.04/24.04 или Debian 12 (1 ГБ ОЗУ хватает), доступ root по SSH. Docker ставить не нужно — установщик поставит его сам.
|
||||||
|
|
||||||
|
**1. Установите панель.** Зайдите на сервер по SSH и выполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
В конце будет строка `Админ-панель: http://IP:8080`.
|
||||||
|
|
||||||
|
**2. Откройте порты** в фаерволе хостинга (панель управления VPS) и, если включён, в `ufw`:
|
||||||
|
|
||||||
|
| Порт | Зачем |
|
||||||
|
|---|---|
|
||||||
|
| `8080/tcp` | админ-панель |
|
||||||
|
| `80/tcp` | страница для пользователей (необязательно, `SKIP_LANDING=1` — без неё) |
|
||||||
|
| UDP-порт AmneziaWG, который выберете на шаге 4 (например `41010/udp`) | VPN AmneziaWG |
|
||||||
|
| TCP-порт Xray, если будете ставить (например `443/tcp`) | VPN Xray |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8080/tcp && sudo ufw allow 41010/udp # пример, если ufw включён
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Войдите** на `http://IP:8080` — пароль **`admin`**. Сразу смените его в блоке «Сменить пароль».
|
||||||
|
|
||||||
|
**4. Разверните протокол.** Раздел «Протоколы / инстансы» → выберите вариант → порт (например `41010`) → «Развернуть инстанс». Какой выбрать:
|
||||||
|
- **AmneziaWG 2.0** — по умолчанию, для телефонов и ПК (приложение Amnezia VPN);
|
||||||
|
- **AmneziaWG 3.1 / 3.0** — самые новые, нужна свежая версия приложения;
|
||||||
|
- **AmneziaWG 1.x** — для роутеров Keenetic / OpenWrt;
|
||||||
|
- **WireGuard** — для любых WG-клиентов, без маскировки.
|
||||||
|
|
||||||
|
Если на сервере уже стоит VPN, поставленный приложением Amnezia, — шаг пропустите: панель найдёт его сама.
|
||||||
|
|
||||||
|
**5. Создайте клиента.** Блок «Новый клиент» → имя → «Создать и скачать .conf». Файл импортируйте в Amnezia VPN (на телефоне/ПК: «+» → «Файл с настройками»).
|
||||||
|
|
||||||
|
**6. (по желанию) Xray VLESS‑Reality** — если AmneziaWG у провайдера режут. Раздел «Xray VLESS‑Reality» → порт TCP (`443`, если свободен) и сайт‑маскировка → «Установить Xray» (~1 минута) → «Создать» клиента — ссылка `vless://` скопируется. Вставьте её в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ («+» → «Из буфера обмена»).
|
||||||
|
|
||||||
|
**7. Свой домен (по желанию).** В «Протоколы / инстансы» → «Домен или IP сервера в конфигах клиентов» → «Сохранить адрес». Без него в конфиги подставляется IP.
|
||||||
|
|
||||||
|
**Обновление** — та же команда из шага 1: клиенты, пароль и настройки сохраняются. **Удаление** — см. «Удаление одной командой».
|
||||||
|
|
||||||
|
**Если не работает:**
|
||||||
|
- «Рукопожатие есть, интернета нет» или нет рукопожатия — не открыт UDP-порт у хостинга (шаг 2).
|
||||||
|
- Панель не открывается — не открыт `8080/tcp`; проверка на сервере: `curl -s localhost:8080/health`.
|
||||||
|
- Xray: «TCP-порт занят» — выберите другой порт (например `8443`) и откройте его.
|
||||||
|
- Логи панели: `sudo docker logs --tail 50 amnezia-admin`.
|
||||||
|
|
||||||
## Что открывается по какому порту
|
## Что открывается по какому порту
|
||||||
|
|
||||||
@@ -32,7 +117,7 @@ Base project **[amnezia_web](https://github.com/andrey271192/amnezia_web)** rema
|
|||||||
### Public install from GitHub
|
### Public install from GitHub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Панель после установки открывается по **`http://IP:HOST_PORT`** (по умолчанию `:8080`). Первый вход: **логин `admin`, пароль `admin`**. Смените пароль сразу после входа.
|
Панель после установки открывается по **`http://IP:HOST_PORT`** (по умолчанию `:8080`). Первый вход: **логин `admin`, пароль `admin`**. Смените пароль сразу после входа.
|
||||||
@@ -42,13 +127,13 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s
|
|||||||
Если `/opt/amnezia-admin` не git-репозиторий, это нормально: установщик скачивает свежий архив с GitHub, делает backup старой папки, сохраняет текущий порт и текущий Docker volume `/data`, затем пересобирает только контейнер панели **`amnezia-admin`**. Контейнеры AmneziaWG (`amnezia-awg`, `amnezia-awg2` и т.д.) не удаляются и не пересоздаются.
|
Если `/opt/amnezia-admin` не git-репозиторий, это нормально: установщик скачивает свежий архив с GitHub, делает backup старой папки, сохраняет текущий порт и текущий Docker volume `/data`, затем пересобирает только контейнер панели **`amnezia-admin`**. Контейнеры AmneziaWG (`amnezia-awg`, `amnezia-awg2` и т.д.) не удаляются и не пересоздаются.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Если не нужен лендинг на порту 80:
|
Если не нужен лендинг на порту 80:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo env SKIP_LANDING=1 bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo env SKIP_LANDING=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
### Переменные окружения и `sudo`
|
### Переменные окружения и `sudo`
|
||||||
@@ -60,7 +145,7 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s
|
|||||||
1. **`sudo -E bash`** вместо `sudo bash` (для большинства VPS достаточно):
|
1. **`sudo -E bash`** вместо `sudo bash` (для большинства VPS достаточно):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWG_PROFILES='[{"id":"awg",…}]' curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash
|
AWG_PROFILES='[{"id":"awg",…}]' curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Файл на VPS** (способ без `-E`): записать JSON **одной строкой** в **`/root/amnezia-admin.awg-profiles.json`** (например `umask 077; printf '%s\n' '[{"id":"awg",…}]' > /root/amnezia-admin.awg-profiles.json`), затем обычный **`curl … | sudo bash`** — установщик подставит профили из файла (см. абзац про снимок ниже таблицы).
|
2. **Файл на VPS** (способ без `-E`): записать JSON **одной строкой** в **`/root/amnezia-admin.awg-profiles.json`** (например `umask 077; printf '%s\n' '[{"id":"awg",…}]' > /root/amnezia-admin.awg-profiles.json`), затем обычный **`curl … | sudo bash`** — установщик подставит профили из файла (см. абзац про снимок ниже таблицы).
|
||||||
@@ -88,7 +173,7 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------|--------------|------------|
|
|------------|--------------|------------|
|
||||||
| `GITHUB_REPO` | `andrey271192/amnezia_web-PRO` | Откуда качать архив |
|
| `GITHUB_REPO` | `PCA/amnezia_web-PRO` | Откуда качать архив |
|
||||||
| `BRANCH` | `main` | Ветка |
|
| `BRANCH` | `main` | Ветка |
|
||||||
| `INSTALL_DIR` | `/opt/amnezia-admin` | Куда распаковать исходники |
|
| `INSTALL_DIR` | `/opt/amnezia-admin` | Куда распаковать исходники |
|
||||||
| `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией |
|
| `DATA_DIR` | `/opt/amnezia-admin-data` | Том с `password.hash` и сессией |
|
||||||
@@ -136,14 +221,14 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-wg0","confPath":"/opt/amnezia/wireguard/wg0.conf","clientsPath":"/opt/amnezia/wireguard/clientsTable","iface":"wg0","wgBinary":"wg","pskPath":"/opt/amnezia/wireguard/wireguard_psk.key"}]' \
|
AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-wg0","confPath":"/opt/amnezia/wireguard/wg0.conf","clientsPath":"/opt/amnezia/wireguard/clientsTable","iface":"wg0","wgBinary":"wg","pskPath":"/opt/amnezia/wireguard/wireguard_psk.key"}]' \
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Пример 2** — оба конфигурационных набора внутри контейнера **`amnezia-awg`** (файл `wg0.conf` рядом с `awg0.conf` в `/opt/amnezia/awg/`):
|
**Пример 2** — оба конфигурационных набора внутри контейнера **`amnezia-awg`** (файл `wg0.conf` рядом с `awg0.conf` в `/opt/amnezia/awg/`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-awg","confPath":"/opt/amnezia/awg/wg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"wg0","wgBinary":"wg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"}]' \
|
AWG_PROFILES='[{"id":"awg","label":"AmneziaWG","container":"amnezia-awg","confPath":"/opt/amnezia/awg/awg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"awg0","wgBinary":"awg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"},{"id":"legacy","label":"AmneziaWG Legacy","container":"amnezia-awg","confPath":"/opt/amnezia/awg/wg0.conf","clientsPath":"/opt/amnezia/awg/clientsTable","iface":"wg0","wgBinary":"wg","pskPath":"/opt/amnezia/awg/wireguard_psk.key"}]' \
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Для **Legacy** часто используется обычный `wg`, для новой AmneziaWG — **`awg`**; поле **`pskPath`** желательно указывать явно, если путь к `wireguard_psk.key` нестандартный.
|
Для **Legacy** часто используется обычный `wg`, для новой AmneziaWG — **`awg`**; поле **`pskPath`** желательно указывать явно, если путь к `wireguard_psk.key` нестандартный.
|
||||||
@@ -230,7 +315,7 @@ cd /opt/amnezia-admin
|
|||||||
Пример установки / обновления с частично пустой панелью:
|
Пример установки / обновления с частично пустой панелью:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash
|
UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Отдельные флаги **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`**, **`UI_HIDE_MTPROTO`** (`1` или `true`) дублируют соответствующий токен.
|
Отдельные флаги **`UI_HIDE_USERS`**, **`UI_HIDE_WARP`**, **`UI_HIDE_CASCADE`**, **`UI_HIDE_MTPROTO`** (`1` или `true`) дублируют соответствующий токен.
|
||||||
@@ -243,7 +328,7 @@ UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andre
|
|||||||
|
|
||||||
Что нужно человеку, который ставит связку:
|
Что нужно человеку, который ставит связку:
|
||||||
|
|
||||||
1. На **этом** (основном) VPS — обычный AmneziaWG и панель PRO; здесь блок **«Новый клиент под каскад»** **создаёт peer и ключи** и выдаёт скачанный **`.conf`**, в котором **Endpoint** уже указывает на промежуточную машину.
|
1. На **этом** (основном) VPS — обычный AmneziaWG и эта панель; здесь блок **«Новый клиент под каскад»** **создаёт peer и ключи** и выдаёт скачанный **`.conf`**, в котором **Endpoint** уже указывает на промежуточную машину.
|
||||||
2. На **промежуточном** сервере нужен **проброс порта DNAT до основного VPS** на тот же **ListenPort**, что слушает туннель здесь (либо свой внешний порт → тогда его указывают в форме как «Порт Endpoint»).
|
2. На **промежуточном** сервере нужен **проброс порта DNAT до основного VPS** на тот же **ListenPort**, что слушает туннель здесь (либо свой внешний порт → тогда его указывают в форме как «Порт Endpoint»).
|
||||||
|
|
||||||
Готовая панель для правил проброса (iptables DNAT NAT «под ключ»): репозиторий **[andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn)** — установка `curl … install.sh | sudo bash`, затем вход в браузере на промежуточный VPS по порту из README (например `8088/setup`) и настройка правила «вход → IP основного VPS:порт WG». Подробные шаги и переменные среды см. там же в README.
|
Готовая панель для правил проброса (iptables DNAT NAT «под ключ»): репозиторий **[andrey271192/kaskad_web_vpn](https://github.com/andrey271192/kaskad_web_vpn)** — установка `curl … install.sh | sudo bash`, затем вход в браузере на промежуточный VPS по порту из README (например `8088/setup`) и настройка правила «вход → IP основного VPS:порт WG». Подробные шаги и переменные среды см. там же в README.
|
||||||
@@ -269,13 +354,13 @@ UI_HIDE_SECTIONS=warp,cascade curl -fsSL https://raw.githubusercontent.com/andre
|
|||||||
Проще всего для пути из исходников — та же команда, что при установке (данные в `DATA_DIR`, пароль и `AWG_PROFILES` сохранятся — профили подтягиваются из файла на VPS или из старого контейнера). Скрипт **подставит прежний внешний порт** контейнера `amnezia-admin`, если вы не передавали `HOST_PORT` и в коде по умолчанию стоит `8080`:
|
Проще всего для пути из исходников — та же команда, что при установке (данные в `DATA_DIR`, пароль и `AWG_PROFILES` сохранятся — профили подтягиваются из файла на VPS или из старого контейнера). Скрипт **подставит прежний внешний порт** контейнера `amnezia-admin`, если вы не передавали `HOST_PORT` и в коде по умолчанию стоит `8080`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Если интерфейс после этого всё ещё старый — принудительно без кэша слоёв Docker:
|
Если интерфейс после этого всё ещё старый — принудительно без кэша слоёв Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo NO_CACHE=1 bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo NO_CACHE=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Если проект уже лежит в `/opt/amnezia-admin` через git и вы подтянули изменения (`git pull`), пересоберите без повторной загрузки архива:
|
Если проект уже лежит в `/opt/amnezia-admin` через git и вы подтянули изменения (`git pull`), пересоберите без повторной загрузки архива:
|
||||||
@@ -301,13 +386,13 @@ sudo docker run --rm amnezia-admin:latest grep -Eo 'Новый клиент по
|
|||||||
## Удаление одной командой
|
## Удаление одной командой
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/uninstall.sh | sudo bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/uninstall.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Полная очистка (контейнер, образ, данные панели и каталог `/opt/amnezia-admin`):
|
Полная очистка (контейнер, образ, данные панели и каталог `/opt/amnezia-admin`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/uninstall.sh | sudo REMOVE_IMAGE=1 REMOVE_DATA=1 REMOVE_SRC=1 bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/uninstall.sh | sudo REMOVE_IMAGE=1 REMOVE_DATA=1 REMOVE_SRC=1 bash
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -326,7 +411,7 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/s
|
|||||||
### Свой пароль при установке
|
### Свой пароль при установке
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ADMIN_PASSWORD='ВашНадёжныйПароль' curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo -E bash
|
ADMIN_PASSWORD='ВашНадёжныйПароль' curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo -E bash
|
||||||
```
|
```
|
||||||
|
|
||||||
После первого успешного старта переменную `ADMIN_PASSWORD` из команды `docker run` убирайте — пароль уже в томе `DATA_DIR`.
|
После первого успешного старта переменную `ADMIN_PASSWORD` из команды `docker run` убирайте — пароль уже в томе `DATA_DIR`.
|
||||||
@@ -358,7 +443,7 @@ ADMIN_PASSWORD=localtest node server.js
|
|||||||
|
|
||||||
| Способ | Ссылка |
|
| Способ | Ссылка |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| GitHub | [amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO) |
|
| GitHub | [amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO) |
|
||||||
| Boosty | [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) |
|
| Boosty | [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) |
|
||||||
| Ozon СБП | [Ozon СБП](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) |
|
| Ozon СБП | [Ozon СБП](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) |
|
||||||
| Telegram | [@lot_andrey](https://t.me/lot_andrey) |
|
| Telegram | [@lot_andrey](https://t.me/lot_andrey) |
|
||||||
@@ -381,7 +466,7 @@ MIT, см. [LICENSE](LICENSE).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- ⭐ **GitHub:** [andrey271192/amnezia_web-PRO](https://github.com/andrey271192/amnezia_web-PRO)
|
- ⭐ **GitHub:** [PCA/amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO)
|
||||||
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
|
- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate)
|
||||||
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
|
- 💳 **Ozon Bank (СБП):** [ссылка](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798)
|
||||||
- ✉️ **Telegram:** [@lot_andrey](https://t.me/lot_andrey)
|
- ✉️ **Telegram:** [@lot_andrey](https://t.me/lot_andrey)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
```
|
```
|
||||||
2. Если строки нет, но `docker ps` показывает два контейнера вида `amnezia-awg*`, переустановите панель свежим установщиком из GitHub:
|
2. Если строки нет, но `docker ps` показывает два контейнера вида `amnezia-awg*`, переустановите панель свежим установщиком из GitHub:
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo env SKIP_LANDING=1 bash
|
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo env SKIP_LANDING=1 bash
|
||||||
```
|
```
|
||||||
Установщик сам создаст **`AWG_PROFILES`** и сохранит **`/root/amnezia-admin.awg-profiles.json`**.
|
Установщик сам создаст **`AWG_PROFILES`** и сохранит **`/root/amnezia-admin.awg-profiles.json`**.
|
||||||
3. Ручной JSON нужен только при нестандартных путях внутри AWG-контейнеров.
|
3. Ручной JSON нужен только при нестандартных путях внутри AWG-контейнеров.
|
||||||
|
|||||||
BIN
docs/screenshots/panel-1.4.png
Normal file
BIN
docs/screenshots/panel-1.4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.2.39",
|
"version": "1.4.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
258
public/app.js
258
public/app.js
@@ -74,77 +74,6 @@ let warpSshPendingCmd = null;
|
|||||||
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
/** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */
|
||||||
let uiHidden = { users: false, warp: false, cascade: false, mtproto: false };
|
let uiHidden = { users: false, warp: false, cascade: false, mtproto: false };
|
||||||
|
|
||||||
const editionBanner = document.querySelector("#edition-banner");
|
|
||||||
const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
|
|
||||||
|
|
||||||
/** Состояние редакции панели (community = только просмотр клиентов). */
|
|
||||||
let editionState = {
|
|
||||||
tier: "pro",
|
|
||||||
readOnlyClients: false,
|
|
||||||
upgradeUrl: null,
|
|
||||||
upgradePitch: null,
|
|
||||||
showDebugWg: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyEditionPayload(data) {
|
|
||||||
const ed = data?.edition;
|
|
||||||
if (!ed || typeof ed !== "object") return;
|
|
||||||
editionState = {
|
|
||||||
tier: ed.tier === "community" ? "community" : "pro",
|
|
||||||
readOnlyClients: Boolean(ed.readOnlyClients),
|
|
||||||
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
|
||||||
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
|
||||||
showDebugWg: ed.showDebugWg !== false,
|
|
||||||
};
|
|
||||||
const titleEl = document.querySelector(".top h1");
|
|
||||||
if (titleEl) {
|
|
||||||
titleEl.textContent =
|
|
||||||
editionState.tier === "community"
|
|
||||||
? "Пользователи AmneziaWG FREE"
|
|
||||||
: "Пользователи AmneziaWG PRO";
|
|
||||||
}
|
|
||||||
const subEl = document.querySelector(".top .sub");
|
|
||||||
if (subEl) {
|
|
||||||
if (editionState.tier === "community") {
|
|
||||||
subEl.textContent =
|
|
||||||
"Базовая панель amnezia_web: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP, MTProto‑прокси Telegram и синхронизация времени хоста — в версии PRO.";
|
|
||||||
} else {
|
|
||||||
subEl.textContent = DEFAULT_HEADER_SUB;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (editionBanner) {
|
|
||||||
if (editionState.tier === "community") {
|
|
||||||
editionBanner.classList.remove("hidden");
|
|
||||||
editionBanner.innerHTML = "";
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "edition-banner-inner";
|
|
||||||
const textCol = document.createElement("div");
|
|
||||||
textCol.className = "edition-banner-text";
|
|
||||||
const strong = document.createElement("strong");
|
|
||||||
strong.textContent = "Базовая версия · только просмотр";
|
|
||||||
const pitch = document.createElement("p");
|
|
||||||
pitch.className = "edition-banner-pitch muted";
|
|
||||||
pitch.textContent = editionState.upgradePitch || "";
|
|
||||||
textCol.append(strong, pitch);
|
|
||||||
const cta = document.createElement("a");
|
|
||||||
cta.className = "btn small primary edition-banner-cta";
|
|
||||||
cta.rel = "noopener noreferrer";
|
|
||||||
cta.target = "_blank";
|
|
||||||
cta.href =
|
|
||||||
editionState.upgradeUrl ||
|
|
||||||
"https://github.com/andrey271192/amnezia_web-PRO";
|
|
||||||
cta.textContent = "Открыть PRO на GitHub";
|
|
||||||
wrap.append(textCol, cta);
|
|
||||||
editionBanner.appendChild(wrap);
|
|
||||||
} else {
|
|
||||||
editionBanner.classList.add("hidden");
|
|
||||||
editionBanner.innerHTML = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
|
|
||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} [tgLink] */
|
/** @param {string} [tgLink] */
|
||||||
function setMtprotoLinkCard(tgLink) {
|
function setMtprotoLinkCard(tgLink) {
|
||||||
const t = typeof tgLink === "string" ? tgLink.trim() : "";
|
const t = typeof tgLink === "string" ? tgLink.trim() : "";
|
||||||
@@ -350,7 +279,7 @@ function applyUiHiddenFromPayload(data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
||||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users;
|
||||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||||
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||||
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||||
@@ -586,7 +515,6 @@ async function checkSession() {
|
|||||||
async function loadProtocols() {
|
async function loadProtocols() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/protocols");
|
const data = await api("/api/protocols");
|
||||||
applyEditionPayload(data);
|
|
||||||
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
||||||
if (profileHintEl) {
|
if (profileHintEl) {
|
||||||
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
|
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
|
||||||
@@ -629,6 +557,8 @@ loginForm.addEventListener("submit", async (ev) => {
|
|||||||
await loadTimeSyncCaps();
|
await loadTimeSyncCaps();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
|
await loadSettings();
|
||||||
|
await loadXray();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loginError.textContent = String(e.message || e);
|
loginError.textContent = String(e.message || e);
|
||||||
}
|
}
|
||||||
@@ -647,6 +577,8 @@ logoutBtn.addEventListener("click", async () => {
|
|||||||
refreshBtn.addEventListener("click", () => {
|
refreshBtn.addEventListener("click", () => {
|
||||||
loadClients();
|
loadClients();
|
||||||
loadInstances();
|
loadInstances();
|
||||||
|
loadSettings();
|
||||||
|
loadXray();
|
||||||
});
|
});
|
||||||
|
|
||||||
const cascadeForm = document.querySelector("#cascade-form");
|
const cascadeForm = document.querySelector("#cascade-form");
|
||||||
@@ -836,14 +768,6 @@ async function loadTimeSyncCaps() {
|
|||||||
const btn = document.querySelector("#sync-host-time");
|
const btn = document.querySelector("#sync-host-time");
|
||||||
try {
|
try {
|
||||||
const c = await api("/api/time-sync-capabilities");
|
const c = await api("/api/time-sync-capabilities");
|
||||||
if (editionState.readOnlyClients || c.communityBlocked) {
|
|
||||||
if (hint) {
|
|
||||||
hint.textContent =
|
|
||||||
"В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO.";
|
|
||||||
}
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hint) {
|
if (hint) {
|
||||||
hint.textContent = c.hostTimeSync
|
hint.textContent = c.hostTimeSync
|
||||||
? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
|
? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
|
||||||
@@ -936,7 +860,6 @@ function renderRows(clients) {
|
|||||||
nameWrap.className = "name-cell";
|
nameWrap.className = "name-cell";
|
||||||
const strong = document.createElement("strong");
|
const strong = document.createElement("strong");
|
||||||
strong.textContent = c.name;
|
strong.textContent = c.name;
|
||||||
if (!editionState.readOnlyClients) {
|
|
||||||
const renameWrap = document.createElement("div");
|
const renameWrap = document.createElement("div");
|
||||||
renameWrap.className = "rename-inline";
|
renameWrap.className = "rename-inline";
|
||||||
renameWrap.appendChild(
|
renameWrap.appendChild(
|
||||||
@@ -959,16 +882,6 @@ function renderRows(clients) {
|
|||||||
hintFold.appendChild(exHint);
|
hintFold.appendChild(exHint);
|
||||||
nameWrap.appendChild(hintFold);
|
nameWrap.appendChild(hintFold);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
nameWrap.appendChild(strong);
|
|
||||||
const roHint = document.createElement("p");
|
|
||||||
roHint.className = "muted hint-mini";
|
|
||||||
roHint.style.margin = "0.35rem 0 0";
|
|
||||||
roHint.textContent = c.exportAvailable
|
|
||||||
? "На сервере есть данные для .conf — скачивание доступно в PRO."
|
|
||||||
: "Нет last_config на сервере — полный конфиг в приложении Amnezia.";
|
|
||||||
nameWrap.appendChild(roHint);
|
|
||||||
}
|
|
||||||
nameTd.appendChild(nameWrap);
|
nameTd.appendChild(nameWrap);
|
||||||
|
|
||||||
const ipTd = document.createElement("td");
|
const ipTd = document.createElement("td");
|
||||||
@@ -989,24 +902,16 @@ function renderRows(clients) {
|
|||||||
const dateLine = document.createElement("div");
|
const dateLine = document.createElement("div");
|
||||||
dateLine.textContent = formatLastDisconnect(c);
|
dateLine.textContent = formatLastDisconnect(c);
|
||||||
offTd.appendChild(dateLine);
|
offTd.appendChild(dateLine);
|
||||||
if (!editionState.readOnlyClients) {
|
|
||||||
const dtWrap = document.createElement("div");
|
const dtWrap = document.createElement("div");
|
||||||
dtWrap.className = "rename-inline";
|
dtWrap.className = "rename-inline";
|
||||||
dtWrap.appendChild(
|
dtWrap.appendChild(
|
||||||
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
|
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
|
||||||
);
|
);
|
||||||
offTd.appendChild(dtWrap);
|
offTd.appendChild(dtWrap);
|
||||||
}
|
|
||||||
|
|
||||||
const actTd = document.createElement("td");
|
const actTd = document.createElement("td");
|
||||||
actTd.className = "actions";
|
actTd.className = "actions";
|
||||||
|
|
||||||
if (editionState.readOnlyClients) {
|
|
||||||
const lock = document.createElement("span");
|
|
||||||
lock.className = "muted";
|
|
||||||
lock.textContent = "Только PRO";
|
|
||||||
actTd.appendChild(lock);
|
|
||||||
} else {
|
|
||||||
if (c.activeInConf) {
|
if (c.activeInConf) {
|
||||||
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
||||||
} else {
|
} else {
|
||||||
@@ -1033,7 +938,6 @@ function renderRows(clients) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
||||||
}
|
|
||||||
|
|
||||||
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
||||||
rowsEl.appendChild(tr);
|
rowsEl.appendChild(tr);
|
||||||
@@ -1649,7 +1553,6 @@ async function loadClients() {
|
|||||||
try {
|
try {
|
||||||
setStatus("Загрузка…", false);
|
setStatus("Загрузка…", false);
|
||||||
const data = await api(`/api/clients${currentProfileQueryString()}`);
|
const data = await api(`/api/clients${currentProfileQueryString()}`);
|
||||||
applyEditionPayload(data);
|
|
||||||
applyUiHiddenFromPayload(data);
|
applyUiHiddenFromPayload(data);
|
||||||
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
||||||
if (uiHidden.users) {
|
if (uiHidden.users) {
|
||||||
@@ -1659,10 +1562,7 @@ async function loadClients() {
|
|||||||
} else {
|
} else {
|
||||||
if (syncPeersBtn) {
|
if (syncPeersBtn) {
|
||||||
syncPeersBtn.hidden = false;
|
syncPeersBtn.hidden = false;
|
||||||
syncPeersBtn.disabled = editionState.readOnlyClients;
|
syncPeersBtn.title = "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
||||||
syncPeersBtn.title = editionState.readOnlyClients
|
|
||||||
? "Доступно в PRO"
|
|
||||||
: "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
|
||||||
}
|
}
|
||||||
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
||||||
wgShowEl.textContent = data.wgShow || "";
|
wgShowEl.textContent = data.wgShow || "";
|
||||||
@@ -1730,6 +1630,8 @@ async function boot() {
|
|||||||
await loadTimeSyncCaps();
|
await loadTimeSyncCaps();
|
||||||
await loadClients();
|
await loadClients();
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
|
await loadSettings();
|
||||||
|
await loadXray();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
loginPassword.focus();
|
loginPassword.focus();
|
||||||
@@ -1738,3 +1640,147 @@ async function boot() {
|
|||||||
|
|
||||||
initMtprotoLinkCopy();
|
initMtprotoLinkCopy();
|
||||||
boot();
|
boot();
|
||||||
|
|
||||||
|
// ── Тема, адрес сервера, вышестоящий сервер каскада ──
|
||||||
|
document.querySelector("#theme-toggle")?.addEventListener("click", () => {
|
||||||
|
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
try { localStorage.setItem("theme", next); } catch { /* private mode */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const input = document.querySelector("#settings-endpoint");
|
||||||
|
const hint = document.querySelector("#settings-hint");
|
||||||
|
if (!input) return;
|
||||||
|
try {
|
||||||
|
const st = await api("/api/settings");
|
||||||
|
input.value = st.endpointHost || "";
|
||||||
|
if (hint) hint.textContent = `Сейчас в конфигах клиентов: ${st.effectiveEndpoint || "адрес из браузера"}. Новый адрес действует на все выгрузки .conf, в том числе старых клиентов.`;
|
||||||
|
} catch { /* старый сервер без /api/settings */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#settings-form")?.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const endpointHost = document.querySelector("#settings-endpoint")?.value.trim() || "";
|
||||||
|
try {
|
||||||
|
const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ endpointHost }) });
|
||||||
|
setStatus(r.resolved ? `Адрес сохранён: ${endpointHost} → ${r.resolved.join(", ")}` : "Адрес сохранён.", false);
|
||||||
|
await loadSettings();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#upstream-form")?.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const configText = document.querySelector("#upstream-conf")?.value || "";
|
||||||
|
const b = ev.target.querySelector("button[type=submit]");
|
||||||
|
if (b) b.disabled = true;
|
||||||
|
try {
|
||||||
|
setStatus("Подключаю вышестоящий сервер…", false);
|
||||||
|
const r = await api("/api/cascade/upstream", { method: "POST", body: JSON.stringify({ configText }) });
|
||||||
|
setStatus(r.handshake ? "Каскад подключён: рукопожатие есть. Отметьте клиентов ниже и нажмите «Применить»." : "Туннель поднят, но рукопожатия пока нет — проверьте Endpoint и UDP-порт вышестоящего сервера.", !r.handshake);
|
||||||
|
await loadClients();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
} finally {
|
||||||
|
if (b) b.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadXray() {
|
||||||
|
const line = document.querySelector("#xray-status-line");
|
||||||
|
try {
|
||||||
|
const x = await api("/api/xray/status");
|
||||||
|
document.querySelector("#xray-install-form").style.display = x.installed ? "none" : "";
|
||||||
|
document.querySelector("#xray-manage").style.display = x.installed ? "" : "none";
|
||||||
|
if (line) {
|
||||||
|
line.textContent = !x.installed
|
||||||
|
? "не установлен"
|
||||||
|
: x.running
|
||||||
|
? `порт ${x.port} · ${x.sni} · клиентов: ${x.clients.length}`
|
||||||
|
: "контейнер остановлен";
|
||||||
|
}
|
||||||
|
if (x.error) setStatus(x.error, true);
|
||||||
|
const rows = document.querySelector("#xray-rows");
|
||||||
|
rows.innerHTML = "";
|
||||||
|
for (const c of x.clients) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const nameTd = document.createElement("td");
|
||||||
|
nameTd.innerHTML = `<strong>${escapeHtml(c.name)}</strong>`;
|
||||||
|
const stTd = document.createElement("td");
|
||||||
|
stTd.innerHTML = `<span class="badge ${c.enabled ? "on" : "off"}">${c.enabled ? "Включён" : "Выключен"}</span>`;
|
||||||
|
const actTd = document.createElement("td");
|
||||||
|
actTd.className = "actions";
|
||||||
|
const act = (action) => async () => {
|
||||||
|
if (action === "delete" && !confirm(`Удалить клиента ${c.name}?`)) return;
|
||||||
|
try {
|
||||||
|
await api("/api/xray/clients/update", { method: "POST", body: JSON.stringify({ id: c.id, action }) });
|
||||||
|
await loadXray();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
actTd.append(
|
||||||
|
iconBtn("⧉", "Копировать ссылку vless://", "btn small ghost icon", async () => {
|
||||||
|
const ok = await copyTextToClipboard(c.link);
|
||||||
|
setStatus(ok ? `Ссылка ${c.name} скопирована.` : "Не удалось скопировать.", !ok);
|
||||||
|
}),
|
||||||
|
c.enabled
|
||||||
|
? iconBtn("⏻", "Выключить", "btn small ghost icon", act("disable"))
|
||||||
|
: iconBtn("▶", "Включить", "btn small primary icon", act("enable")),
|
||||||
|
iconBtn("🗑", "Удалить", "btn small warn icon", act("delete")),
|
||||||
|
);
|
||||||
|
tr.append(nameTd, stTd, actTd);
|
||||||
|
rows.appendChild(tr);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (line) line.textContent = String(e.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#xray-install-form")?.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const b = ev.target.querySelector("button[type=submit]");
|
||||||
|
if (b) b.disabled = true;
|
||||||
|
try {
|
||||||
|
setStatus("Ставлю Xray (сборка образа ~1 мин)…", false);
|
||||||
|
await api("/api/xray/install", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
port: document.querySelector("#xray-port").value,
|
||||||
|
sni: document.querySelector("#xray-sni").value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setStatus("Xray установлен. Создайте клиента.", false);
|
||||||
|
await loadXray();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
} finally {
|
||||||
|
if (b) b.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#xray-client-form")?.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const input = document.querySelector("#xray-client-name");
|
||||||
|
try {
|
||||||
|
const r = await api("/api/xray/clients/create", { method: "POST", body: JSON.stringify({ name: input.value }) });
|
||||||
|
input.value = "";
|
||||||
|
const ok = await copyTextToClipboard(r.link);
|
||||||
|
setStatus(ok ? "Клиент создан, ссылка скопирована." : "Клиент создан.", false);
|
||||||
|
await loadXray();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#xray-remove")?.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Удалить контейнер Xray? Клиенты Xray перестанут работать.")) return;
|
||||||
|
try {
|
||||||
|
await api("/api/xray/remove", { method: "POST", body: "{}" });
|
||||||
|
await loadXray();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(String(e.message || e), true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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?v=1.2.28">
|
<script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script>
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=1.4.2">
|
||||||
</head>
|
</head>
|
||||||
<body class="page">
|
<body class="page">
|
||||||
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
|
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
|
||||||
<div class="panel-promo-strip-inner">
|
<div class="panel-promo-strip-inner">
|
||||||
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
|
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
|
||||||
<div class="panel-promo-links">
|
<div class="panel-promo-links">
|
||||||
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · PRO</a>
|
<a class="panel-promo-a" href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ Git</a>
|
||||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||||
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
||||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<nav class="gate-donate muted" aria-label="Поддержка и донат">
|
<nav class="gate-donate muted" aria-label="Поддержка и донат">
|
||||||
Поддержка веб‑панелей (вне этого сервера):<br/>
|
Поддержка веб‑панелей (вне этого сервера):<br/>
|
||||||
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub · PRO</a>
|
<a href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
|
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
|
||||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="token-box">
|
<div class="token-box">
|
||||||
<div class="session-actions">
|
<div class="session-actions">
|
||||||
|
<button type="button" id="theme-toggle" class="btn ghost full" title="Светлая / тёмная тема">◐ Тема</button>
|
||||||
<button type="button" id="logout" class="btn ghost full">Выйти</button>
|
<button type="button" id="logout" class="btn ghost full">Выйти</button>
|
||||||
</div>
|
</div>
|
||||||
<details class="pw-change">
|
<details class="pw-change">
|
||||||
@@ -76,7 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
|
|
||||||
|
|
||||||
<section class="toolbar">
|
<section class="toolbar">
|
||||||
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
<summary class="fold-summary">
|
<summary class="fold-summary">
|
||||||
<span class="fold-arrow" aria-hidden="true"></span>
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
<span class="fold-titles">
|
<span class="fold-titles">
|
||||||
<span class="fold-h">Cloudflare WARP</span>
|
<span class="fold-h">Cloudflare WARP / каскад</span>
|
||||||
<span class="muted fold-meta" id="warp-status-line"></span>
|
<span class="muted fold-meta" id="warp-status-line"></span>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
@@ -132,6 +133,11 @@
|
|||||||
Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>.
|
Один раз на хосте 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.
|
Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README.
|
||||||
</p>
|
</p>
|
||||||
|
<form id="upstream-form" class="cascade-form">
|
||||||
|
<label for="upstream-conf"><strong>Каскад через другой сервер</strong> — вставьте клиентский .conf (AmneziaWG или WireGuard) другого сервера. Он станет выходом вместо WARP: отмеченные ниже клиенты пойдут в интернет через тот сервер.</label>
|
||||||
|
<textarea id="upstream-conf" rows="6" placeholder="[Interface] PrivateKey = … [Peer] Endpoint = …" autocomplete="off"></textarea>
|
||||||
|
<button type="submit" class="btn primary">Подключить вышестоящий сервер</button>
|
||||||
|
</form>
|
||||||
<div id="warp-actions" class="warp-actions"></div>
|
<div id="warp-actions" class="warp-actions"></div>
|
||||||
<div id="warp-client-list" class="warp-client-list"></div>
|
<div id="warp-client-list" class="warp-client-list"></div>
|
||||||
<details class="raw warp-raw">
|
<details class="raw warp-raw">
|
||||||
@@ -183,6 +189,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="panel-fold" id="xray-panel">
|
||||||
|
<summary class="fold-summary">
|
||||||
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
|
<span class="fold-titles">
|
||||||
|
<span class="fold-h">Xray VLESS‑Reality</span>
|
||||||
|
<span class="muted fold-meta" id="xray-status-line"></span>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="panel-fold-body">
|
||||||
|
<p class="muted warp-intro">
|
||||||
|
Протокол для мест, где блокируют AmneziaWG: трафик выглядит как обычный HTTPS к сайту‑маскировке.
|
||||||
|
Ссылку <code class="inline">vless://</code> вставьте в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ. Нужен открытый TCP‑порт.
|
||||||
|
</p>
|
||||||
|
<form id="xray-install-form" class="instance-form">
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-port">Порт (TCP)</label>
|
||||||
|
<input id="xray-port" type="number" min="1" max="65535" value="443">
|
||||||
|
</div>
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-sni">Сайт‑маскировка</label>
|
||||||
|
<input id="xray-sni" type="text" value="www.google.com" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">+ Установить Xray</button>
|
||||||
|
</form>
|
||||||
|
<div id="xray-manage" style="display:none">
|
||||||
|
<form id="xray-client-form" class="instance-form">
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="xray-client-name">Новый клиент</label>
|
||||||
|
<input id="xray-client-name" type="text" maxlength="64" placeholder="Имя" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">+ Создать</button>
|
||||||
|
<button type="button" id="xray-remove" class="btn small warn">Удалить Xray</button>
|
||||||
|
</form>
|
||||||
|
<table class="clients-table"><tbody id="xray-rows"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<details class="panel-fold" id="instances-panel" open>
|
<details class="panel-fold" id="instances-panel" open>
|
||||||
<summary class="fold-summary">
|
<summary class="fold-summary">
|
||||||
<span class="fold-arrow" aria-hidden="true"></span>
|
<span class="fold-arrow" aria-hidden="true"></span>
|
||||||
@@ -198,8 +242,10 @@
|
|||||||
<label for="instance-variant">Протокол</label>
|
<label for="instance-variant">Протокол</label>
|
||||||
<select id="instance-variant" class="form-select">
|
<select id="instance-variant" class="form-select">
|
||||||
<option value="awg2">AmneziaWG 2.0 (рекомендуется)</option>
|
<option value="awg2">AmneziaWG 2.0 (рекомендуется)</option>
|
||||||
<option value="awg">AmneziaWG</option>
|
<option value="awg31">AmneziaWG 3.1 — новейший, свежее приложение Amnezia</option>
|
||||||
<option value="legacy">AmneziaWG Legacy</option>
|
<option value="awg3">AmneziaWG 3.0</option>
|
||||||
|
<option value="awg">AmneziaWG 1.x — роутеры Keenetic/OpenWrt, старые приложения</option>
|
||||||
|
<option value="legacy">WireGuard (Legacy) — любой WG-клиент, без обфускации</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="instance-form-row">
|
<div class="instance-form-row">
|
||||||
@@ -208,7 +254,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn primary">+ Развернуть инстанс</button>
|
<button type="submit" class="btn primary">+ Развернуть инстанс</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов.</p>
|
<p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов. Можно держать несколько версий рядом на разных портах: 3.1/2.0 — для телефонов и ПК, 1.x или WireGuard — для роутеров. Не забудьте открыть UDP-порт в фаерволе хостинга.</p>
|
||||||
|
<form id="settings-form" class="instance-form">
|
||||||
|
<div class="instance-form-row">
|
||||||
|
<label for="settings-endpoint">Домен или IP сервера в конфигах клиентов</label>
|
||||||
|
<input id="settings-endpoint" type="text" placeholder="vpn.example.com — пусто: IP из установки" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn primary">Сохранить адрес</button>
|
||||||
|
</form>
|
||||||
|
<p id="settings-hint" class="muted instance-hint"></p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -359,7 +413,7 @@
|
|||||||
<div class="support-line">
|
<div class="support-line">
|
||||||
<strong class="support-title">amnezia_web-PRO</strong>
|
<strong class="support-title">amnezia_web-PRO</strong>
|
||||||
<span class="support-sep" aria-hidden="true">·</span>
|
<span class="support-sep" aria-hidden="true">·</span>
|
||||||
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · amnezia_web-PRO</a>
|
<a href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ Git · amnezia_web-PRO</a>
|
||||||
<span class="support-sep" aria-hidden="true">·</span>
|
<span class="support-sep" aria-hidden="true">·</span>
|
||||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
||||||
<span class="support-sep" aria-hidden="true">·</span>
|
<span class="support-sep" aria-hidden="true">·</span>
|
||||||
@@ -406,6 +460,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="/app.js?v=1.2.28" type="module"></script>
|
<script src="/app.js?v=1.4.2" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
--danger: #fb7185;
|
--danger: #fb7185;
|
||||||
--ok: #4ade80;
|
--ok: #4ade80;
|
||||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||||
|
--field: #0a0f16;
|
||||||
|
--text-soft: #cbd5f5;
|
||||||
|
--overlay: rgba(10, 15, 22, 0.92);
|
||||||
|
color-scheme: dark;
|
||||||
font-family: "DM Sans", system-ui, sans-serif;
|
font-family: "DM Sans", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +19,28 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Светлая тема: те же переменные, переключатель в шапке (localStorage "theme"). */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: rgba(15, 23, 42, 0.12);
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #526077;
|
||||||
|
--accent: #0369a1;
|
||||||
|
--danger: #be123c;
|
||||||
|
--ok: #15803d;
|
||||||
|
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||||
|
--field: #ffffff;
|
||||||
|
--text-soft: #1e293b;
|
||||||
|
--overlay: rgba(255, 255, 255, 0.94);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .panel-promo-strip {
|
||||||
|
background: linear-gradient(90deg, #e0f2fe, #eef2ff);
|
||||||
|
border-bottom-color: rgba(3, 105, 161, 0.25);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .panel-promo-a { color: #92400e; }
|
||||||
|
|
||||||
body.page {
|
body.page {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
|
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
|
||||||
@@ -123,7 +149,7 @@ h1 {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #0c121b;
|
background: var(--field);
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-box label {
|
.token-box label {
|
||||||
@@ -191,7 +217,7 @@ h1 {
|
|||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -255,7 +281,7 @@ h1 {
|
|||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -265,7 +291,7 @@ h1 {
|
|||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -294,7 +320,7 @@ h1 {
|
|||||||
padding: 0.45rem 0.55rem;
|
padding: 0.45rem 0.55rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -407,7 +433,7 @@ h1 {
|
|||||||
padding: 0.45rem 0.55rem;
|
padding: 0.45rem 0.55rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -715,7 +741,7 @@ tr:last-child td {
|
|||||||
.ip {
|
.ip {
|
||||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #dbeafe;
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@@ -736,7 +762,7 @@ tr:last-child td {
|
|||||||
|
|
||||||
.badge.off {
|
.badge.off {
|
||||||
border-color: rgba(148, 163, 184, 0.35);
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
color: #e2e8f0;
|
color: var(--text-soft);
|
||||||
background: rgba(148, 163, 184, 0.06);
|
background: rgba(148, 163, 184, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,11 +859,11 @@ tr:last-child td {
|
|||||||
.cascade-snippet {
|
.cascade-snippet {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 0.75rem 0.95rem;
|
padding: 0.75rem 0.95rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #cbd5f5;
|
color: var(--text-soft);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
margin: 0.5rem 0 0.85rem;
|
margin: 0.5rem 0 0.85rem;
|
||||||
@@ -884,11 +910,11 @@ tr:last-child td {
|
|||||||
.raw pre {
|
.raw pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #cbd5f5;
|
color: var(--text-soft);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +953,7 @@ tr:last-child td {
|
|||||||
padding: 0.55rem 0.65rem;
|
padding: 0.55rem 0.65rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: #0a0f16;
|
background: var(--field);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -975,7 +1001,7 @@ tr:last-child td {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
|
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: rgba(10, 15, 22, 0.92);
|
background: var(--overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-line {
|
.support-line {
|
||||||
@@ -1071,36 +1097,6 @@ tr:last-child td {
|
|||||||
overflow: auto;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Telegram MTProto --- */
|
/* --- Telegram MTProto --- */
|
||||||
.mtproto-link-card {
|
.mtproto-link-card {
|
||||||
margin: 0.65rem 0 0.85rem;
|
margin: 0.65rem 0 0.85rem;
|
||||||
@@ -1227,3 +1223,12 @@ tr:last-child td {
|
|||||||
.instance-empty { padding: 12px; }
|
.instance-empty { padding: 12px; }
|
||||||
|
|
||||||
.inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; }
|
.inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* Светлая тема: пастельные надписи тёмной темы на белом не читаются */
|
||||||
|
html[data-theme="light"] .clock-zone-diff--accent,
|
||||||
|
html[data-theme="light"] .cascade-guide a,
|
||||||
|
html[data-theme="light"] .cascade-guide a:hover { color: #0369a1; }
|
||||||
|
html[data-theme="light"] .btn.warn { color: #9f1239; }
|
||||||
|
html[data-theme="light"] .status.err { color: #b91c1c; }
|
||||||
|
html[data-theme="light"] .badge.on { color: #166534; }
|
||||||
|
html[data-theme="light"] body.page { background: var(--bg); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy / remove an AmneziaWG server instance from public images — no Amnezia app.
|
# Deploy / remove an AmneziaWG server instance from public images — no Amnezia app.
|
||||||
# Variants: awg2 (AmneziaWG 2.0, awg-go 2.0.0), awg (classic, awg-go 0.2.18),
|
# Variants: awg31 (AmneziaWG 3.1), awg3 (3.0), awg2 (2.0, awg-go 2.0.0),
|
||||||
# legacy (kernel WireGuard, amnezia-wg).
|
# awg (1.x for routers/old apps, awg-go 0.2.18), legacy (plain WireGuard, amnezia-wg).
|
||||||
# Usage:
|
# Usage:
|
||||||
# awg-instance.sh create <variant> <port> [name]
|
# awg-instance.sh create <variant> <port> [name]
|
||||||
# awg-instance.sh remove <name>
|
# awg-instance.sh remove <name>
|
||||||
@@ -12,6 +12,9 @@ INSTANCES_DIR="${INSTANCES_DIR:-/opt/amnezia-instances}"
|
|||||||
IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}"
|
IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}"
|
||||||
IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}"
|
IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}"
|
||||||
IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}"
|
IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}"
|
||||||
|
# 3.x pinned by digest: tags on Docker Hub get rewritten (tools inside differ from the tag name)
|
||||||
|
IMG_AWG3="${IMG_AWG3:-amneziavpn/amneziawg-go:3.0.20260805@sha256:8447c91637c37536dd99b8bbd4420c819ac9f330f047804197291625bfb0ea8a}"
|
||||||
|
IMG_AWG31="${IMG_AWG31:-amneziavpn/amneziawg-go:3.1.20260828@sha256:cbafc02b8373a83f428272db6d8001b37bc02e6211cbd8c0cb4e2e3759b12b72}"
|
||||||
|
|
||||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -28,10 +31,12 @@ rand_magic() { # single large uint32 (AmneziaWG H header), NOT a range
|
|||||||
|
|
||||||
image_for() {
|
image_for() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
awg31) echo "$IMG_AWG31" ;;
|
||||||
|
awg3) echo "$IMG_AWG3" ;;
|
||||||
awg2) echo "$IMG_AWG2" ;;
|
awg2) echo "$IMG_AWG2" ;;
|
||||||
awg) echo "$IMG_AWG" ;;
|
awg) echo "$IMG_AWG" ;;
|
||||||
legacy) echo "$IMG_LEGACY" ;;
|
legacy) echo "$IMG_LEGACY" ;;
|
||||||
*) err "unknown variant: $1 (awg2|awg|legacy)" ;;
|
*) err "unknown variant: $1 (awg31|awg3|awg2|awg|legacy)" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +68,8 @@ cmd_create() {
|
|||||||
[[ -n "$name" ]] || err "bad name"
|
[[ -n "$name" ]] || err "bad name"
|
||||||
docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists"
|
docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists"
|
||||||
# port free?
|
# port free?
|
||||||
if ss -lun 2>/dev/null | grep -qE "[:.]${port}\b"; then err "udp port $port busy"; fi
|
# this script runs inside the panel container: its own `ss` does not see host ports, docker does
|
||||||
|
if docker ps --format '{{.Ports}}' | tr ',' '\n' | grep -qE ":${port}->[0-9]+/udp"; then err "udp port $port busy (другой контейнер)"; fi
|
||||||
|
|
||||||
echo "→ pulling $img"
|
echo "→ pulling $img"
|
||||||
docker pull -q "$img" >/dev/null
|
docker pull -q "$img" >/dev/null
|
||||||
@@ -87,22 +93,37 @@ cmd_create() {
|
|||||||
{
|
{
|
||||||
echo "[Interface]"
|
echo "[Interface]"
|
||||||
echo "PrivateKey = $priv"
|
echo "PrivateKey = $priv"
|
||||||
echo "Address = ${net}.0/24"
|
echo "Address = ${net}.1/24"
|
||||||
echo "ListenPort = $port"
|
echo "ListenPort = $port"
|
||||||
if [[ "$variant" != "legacy" ]]; then
|
if [[ "$variant" != "legacy" ]]; then
|
||||||
|
# 3.x header protection rejects junk sizes <= 12
|
||||||
|
local smin=15
|
||||||
echo "Jc = $(rand_range 3 10)"
|
echo "Jc = $(rand_range 3 10)"
|
||||||
echo "Jmin = 10"
|
echo "Jmin = 10"
|
||||||
echo "Jmax = 50"
|
echo "Jmax = 50"
|
||||||
echo "S1 = $(rand_range 15 60)"
|
echo "S1 = $(rand_range $smin 60)"
|
||||||
echo "S2 = $(rand_range 15 60)"
|
echo "S2 = $(rand_range $smin 60)"
|
||||||
if [[ "$variant" == "awg2" ]]; then
|
if [[ "$variant" != "awg" ]]; then
|
||||||
echo "S3 = $(rand_range 5 40)"
|
echo "S3 = $(rand_range $smin 40)"
|
||||||
echo "S4 = $(rand_range 1 40)"
|
echo "S4 = $(rand_range $smin 40)"
|
||||||
fi
|
fi
|
||||||
echo "H1 = $(rand_magic)"
|
echo "H1 = $(rand_magic)"
|
||||||
echo "H2 = $(rand_magic)"
|
echo "H2 = $(rand_magic)"
|
||||||
echo "H3 = $(rand_magic)"
|
echo "H3 = $(rand_magic)"
|
||||||
echo "H4 = $(rand_magic)"
|
echo "H4 = $(rand_magic)"
|
||||||
|
if [[ "$variant" == awg3* ]]; then
|
||||||
|
echo "HeaderProtectionKey = $(docker run --rm "$img" awg genkey | tr -d '\r\n')"
|
||||||
|
echo "ContentPaddingAddition = 10-100"
|
||||||
|
echo "RekeyAfterTime = 100-120"
|
||||||
|
echo "RekeyTimeout = 3-7"
|
||||||
|
echo "RejectAfterTime = 150-180"
|
||||||
|
echo "KeepaliveTimeout = 5-15"
|
||||||
|
echo "MaxHandshakeAttempts = 15-20"
|
||||||
|
fi
|
||||||
|
if [[ "$variant" == "awg31" ]]; then
|
||||||
|
echo "RandomTrailers = on"
|
||||||
|
echo "DisableCookies = on"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
} > "$conf"
|
} > "$conf"
|
||||||
|
|
||||||
@@ -114,6 +135,13 @@ cmd_create() {
|
|||||||
# start script run inside container
|
# start script run inside container
|
||||||
local usimpl=""
|
local usimpl=""
|
||||||
if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi
|
if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi
|
||||||
|
# 3.x is userspace-only: a loaded amneziawg kernel module (2.0 on the same host) does not know 3.x keys
|
||||||
|
if [[ "$variant" == awg3* ]]; then
|
||||||
|
usimpl="$usimpl"$'\n'"$(cat <<'PATCH'
|
||||||
|
sed -i -e 's/if ! cmd ip link add "$INTERFACE" type amneziawg; then/if true; then/' -e 's|^\([[:space:]]*\)\[\[ -e /sys/module/amneziawg \]\].*|\1:|' /usr/bin/awg-quick
|
||||||
|
PATCH
|
||||||
|
)"
|
||||||
|
fi
|
||||||
cat > "$dir/start.sh" <<EOF
|
cat > "$dir/start.sh" <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set +e
|
set +e
|
||||||
@@ -156,7 +184,7 @@ cmd_remove() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_list() {
|
cmd_list() {
|
||||||
docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-(awg2|awg|legacy)-|^amnezia-' || true
|
docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
|
|||||||
53
scripts/checks/audit2.py
Normal file
53
scripts/checks/audit2.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Сверяем ПОДНЯТЫЕ интерфейсы с их собственными конфигами.
|
||||||
|
|
||||||
|
Первый заход сравнивал wg0.conf с интерфейсом wg0, а в контейнере amnezia-awg
|
||||||
|
поднят awg0 — отсюда ложные «расхождения».
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
|
||||||
|
PROBE = r"""
|
||||||
|
for c in $(docker ps --format '{{.Names}}' 2>/dev/null | grep '^amnezia-' | grep -vE 'nginx|frontend|backend|landing|xray|socks|ipsec|openvpn'); do
|
||||||
|
for bin in awg wg; do
|
||||||
|
docker exec $c sh -c "command -v $bin >/dev/null" 2>/dev/null || continue
|
||||||
|
for i in $(docker exec $c sh -c "$bin show interfaces 2>/dev/null"); do
|
||||||
|
live=$(docker exec $c sh -c "$bin show $i peers 2>/dev/null | grep -c ." | tr -d '\n')
|
||||||
|
conf=""
|
||||||
|
for f in /opt/amnezia/awg/$i.conf /opt/amnezia/awg/awg0.conf /opt/amnezia/awg/wg0.conf; do
|
||||||
|
docker exec $c test -f $f 2>/dev/null && { conf=$f; break; }
|
||||||
|
done
|
||||||
|
[ -n "$conf" ] || { echo "$c $i live=$live конфиг=НЕ НАЙДЕН"; continue; }
|
||||||
|
infile=$(docker exec $c grep -c '^\[Peer\]' $conf 2>/dev/null | tr -d '\n')
|
||||||
|
echo "$c $i live=$live файл=$infile ($conf)"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
done
|
||||||
|
"""
|
||||||
|
|
||||||
|
d = json.load(open("/app/data/data.json"))
|
||||||
|
плохо = 0
|
||||||
|
for s in d["servers"]:
|
||||||
|
try:
|
||||||
|
with SSHManager(s["host"], s.get("ssh_port", 22), s["username"],
|
||||||
|
password=s.get("password") or None,
|
||||||
|
private_key=s.get("private_key") or None,
|
||||||
|
connect_timeout=8) as ssh:
|
||||||
|
out, err, _ = ssh.run_command(PROBE, timeout=60)
|
||||||
|
строки = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||||
|
if not строки:
|
||||||
|
print(" ? %-26s нет поднятых интерфейсов" % s["name"][:26])
|
||||||
|
continue
|
||||||
|
for стр in строки:
|
||||||
|
беда = False
|
||||||
|
if "live=" in стр and "файл=" in стр:
|
||||||
|
l = int(стр.split("live=")[1].split()[0])
|
||||||
|
f = int(стр.split("файл=")[1].split()[0])
|
||||||
|
беда = (l != f)
|
||||||
|
плохо += 1 if беда else 0
|
||||||
|
print(" %s %-26s %s" % ("!!" if беда else "ok", s["name"][:26], стр))
|
||||||
|
except Exception as e:
|
||||||
|
плохо += 1
|
||||||
|
print(" !! %-26s НЕ ДОСТУЧАЛСЯ: %s" % (s["name"][:26], str(e)[:60]))
|
||||||
|
print("\nрасхождений:", плохо)
|
||||||
56
scripts/checks/audit_conf.py
Normal file
56
scripts/checks/audit_conf.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Целостность конфигов awg по всему парку: склейки, финальный перевод строки,
|
||||||
|
проходит ли awg-quick strip и совпадает ли число пиров с живым интерфейсом."""
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
|
||||||
|
PROBE = r"""
|
||||||
|
for c in amnezia-awg2 amnezia-awg amnezia-awg31 amnezia-awg3 amnezia-awg-legacy; do
|
||||||
|
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx $c || continue
|
||||||
|
for f in awg0.conf wg0.conf; do
|
||||||
|
p=/opt/amnezia/awg/$f
|
||||||
|
docker exec $c test -f $p 2>/dev/null || continue
|
||||||
|
glue=$(docker exec $c grep -c '/32\[Peer\]' $p 2>/dev/null | tr -d '\n')
|
||||||
|
end=$(docker exec $c sh -c "tail -c 1 $p | od -An -c | tr -d ' \n'")
|
||||||
|
peers=$(docker exec $c grep -c '^\[Peer\]' $p 2>/dev/null | tr -d '\n')
|
||||||
|
mode=$(docker exec $c stat -c %a $p 2>/dev/null | tr -d '\n')
|
||||||
|
bin=wg; iface=wg0
|
||||||
|
case $f in awg0.conf) bin=awg; iface=awg0;; esac
|
||||||
|
if docker exec $c sh -c "$bin-quick strip $p > /dev/null 2>&1"; then strip=ok; else strip=СБОЙ; fi
|
||||||
|
live=$(docker exec $c sh -c "$bin show $iface peers 2>/dev/null | grep -c ." | tr -d '\n')
|
||||||
|
echo "$c/$f peers=$peers live=$live glue=$glue end=$end mode=$mode strip=$strip"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
"""
|
||||||
|
|
||||||
|
d = json.load(open("/app/data/data.json"))
|
||||||
|
беда = 0
|
||||||
|
for s in d["servers"]:
|
||||||
|
try:
|
||||||
|
with SSHManager(s["host"], s.get("ssh_port", 22), s["username"],
|
||||||
|
password=s.get("password") or None,
|
||||||
|
private_key=s.get("private_key") or None,
|
||||||
|
connect_timeout=8) as ssh:
|
||||||
|
out, err, _ = ssh.run_command(PROBE, timeout=60)
|
||||||
|
строки = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
||||||
|
if not строки:
|
||||||
|
print(" ? %-26s нет контейнеров awg (%s)" % (s["name"][:26], (err or "")[:40]))
|
||||||
|
continue
|
||||||
|
for стр in строки:
|
||||||
|
плохо = ("glue=0" not in стр) or ("strip=ok" not in стр) or ("end=\\n" not in стр)
|
||||||
|
if "peers=" in стр and "live=" in стр:
|
||||||
|
try:
|
||||||
|
p = int(стр.split("peers=")[1].split()[0])
|
||||||
|
l = int(стр.split("live=")[1].split()[0])
|
||||||
|
if p != l:
|
||||||
|
плохо = True
|
||||||
|
стр += " ← файл и интерфейс расходятся"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
беда += 1 if плохо else 0
|
||||||
|
print(" %s %-26s %s" % ("!!" if плохо else "ok", s["name"][:26], стр))
|
||||||
|
except Exception as e:
|
||||||
|
беда += 1
|
||||||
|
print(" !! %-26s НЕ ДОСТУЧАЛСЯ: %s" % (s["name"][:26], str(e)[:60]))
|
||||||
|
print("\nпроблемных строк:", беда)
|
||||||
38
scripts/checks/check_conf.py
Normal file
38
scripts/checks/check_conf.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ищем склеенные строки в конфигах awg на всех серверах панели.
|
||||||
|
|
||||||
|
Имена переменных в пробе латиницей: sh внутри контейнеров кириллицу не берёт.
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
|
||||||
|
ПРОБА = (
|
||||||
|
"for c in amnezia-awg2 amnezia-awg amnezia-awg31 amnezia-awg3 amnezia-awg-legacy; do "
|
||||||
|
" docker ps --format '{{.Names}}' 2>/dev/null | grep -qx $c || continue; "
|
||||||
|
" for f in awg0.conf wg0.conf; do "
|
||||||
|
" p=/opt/amnezia/awg/$f; "
|
||||||
|
" docker exec $c test -f $p 2>/dev/null || continue; "
|
||||||
|
" glue=$(docker exec $c grep -c '/32\\[Peer\\]' $p 2>/dev/null || echo 0); "
|
||||||
|
" endnl=$(docker exec $c sh -c \"tail -c 1 $p | od -An -c | tr -d ' '\" 2>/dev/null); "
|
||||||
|
" peers=$(docker exec $c grep -c '^\\[Peer\\]' $p 2>/dev/null || echo 0); "
|
||||||
|
" echo \"$c/$f peers=$peers glue=$glue end=$endnl\"; "
|
||||||
|
" done; done"
|
||||||
|
)
|
||||||
|
|
||||||
|
d = json.load(open("/app/data/data.json"))
|
||||||
|
for s in d["servers"]:
|
||||||
|
try:
|
||||||
|
with SSHManager(s["host"], s.get("ssh_port", 22), s["username"],
|
||||||
|
password=s.get("password") or None,
|
||||||
|
private_key=s.get("private_key") or None,
|
||||||
|
connect_timeout=8) as ssh:
|
||||||
|
out, err, _ = ssh.run_command(ПРОБА, timeout=40)
|
||||||
|
for строка in (out or err).splitlines():
|
||||||
|
строка = строка.strip()
|
||||||
|
if not строка:
|
||||||
|
continue
|
||||||
|
плохо = ("glue=0" not in строка) or ("end=\\n" not in строка)
|
||||||
|
print("%s %-26s %s" % (" !!" if плохо else " ", s["name"][:26], строка))
|
||||||
|
except Exception as e:
|
||||||
|
print(" %-26s СБОЙ: %s" % (s["name"][:26], str(e)[:50]))
|
||||||
30
scripts/checks/check_garbage.py
Normal file
30
scripts/checks/check_garbage.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Не осталось ли от проверок тестовых клиентов в clientsTable и в data.json."""
|
||||||
|
import json, sys
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
|
||||||
|
МЕТКА = "__проверка_связи"
|
||||||
|
d = json.load(open("/app/data/data.json"))
|
||||||
|
|
||||||
|
в_панели = [c for c in json.dumps(d, ensure_ascii=False).split('"') if МЕТКА in c]
|
||||||
|
print("следов в data.json панели:", len(в_панели))
|
||||||
|
|
||||||
|
ПРОБА = (
|
||||||
|
"for c in $(docker ps --format '{{.Names}}' 2>/dev/null | grep -E 'amnezia-(awg|wireguard|xray)'); do "
|
||||||
|
" n=$(docker exec $c sh -c \"grep -o '%s[^\\\"]*' /opt/amnezia/awg/clientsTable /opt/amnezia/xray/clientsTable 2>/dev/null | wc -l\"); "
|
||||||
|
" [ \"$n\" != \"0\" ] && echo \"$c: $n\"; done; echo готово" % МЕТКА
|
||||||
|
)
|
||||||
|
всего = 0
|
||||||
|
for s in d["servers"]:
|
||||||
|
try:
|
||||||
|
with SSHManager(s["host"], s.get("ssh_port", 22), s["username"],
|
||||||
|
password=s.get("password") or None,
|
||||||
|
private_key=s.get("private_key") or None, connect_timeout=8) as ssh:
|
||||||
|
o, _, _ = ssh.run_command(ПРОБА, timeout=40)
|
||||||
|
строки = [x for x in (o or "").splitlines() if x.strip() and "готово" not in x]
|
||||||
|
if строки:
|
||||||
|
всего += len(строки)
|
||||||
|
print(" !!", s["name"], строки)
|
||||||
|
except Exception as e:
|
||||||
|
print(" ?", s["name"][:26], str(e)[:40])
|
||||||
|
print("серверов с остатками:", всего)
|
||||||
59
scripts/checks/test_create.py
Normal file
59
scripts/checks/test_create.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Боевая проверка: на каждом сервере и каждом протоколе создаём тестового
|
||||||
|
клиента и тут же удаляем. Именно так работает кнопка «Добавить подключение».
|
||||||
|
|
||||||
|
Считаем пиров до и после: если после удаления число не вернулось — значит
|
||||||
|
за нами остался мусор, и это надо видеть.
|
||||||
|
"""
|
||||||
|
import json, sys, time
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
from managers.awg_manager import AWGManager
|
||||||
|
|
||||||
|
ИМЯ = "__проверка_связи_" + time.strftime("%d%m_%H%M")
|
||||||
|
ПРОТОКОЛЫ = ("awg", "awg2", "awg3", "awg31", "wireguard")
|
||||||
|
|
||||||
|
d = json.load(open("/app/data/data.json"))
|
||||||
|
итог = {"ok": 0, "сбой": 0, "мусор": 0}
|
||||||
|
|
||||||
|
for s in d["servers"]:
|
||||||
|
протоколы = [p for p in ПРОТОКОЛЫ
|
||||||
|
if (s.get("protocols", {}).get(p) or {}).get("installed")]
|
||||||
|
if not протоколы:
|
||||||
|
print(" – %-26s протоколов AWG не заведено" % s["name"][:26])
|
||||||
|
continue
|
||||||
|
for proto in протоколы:
|
||||||
|
метка = "%s / %s" % (s["name"][:22], proto)
|
||||||
|
try:
|
||||||
|
ssh = SSHManager(s["host"], s.get("ssh_port", 22), s["username"],
|
||||||
|
password=s.get("password") or None,
|
||||||
|
private_key=s.get("private_key") or None,
|
||||||
|
connect_timeout=10, connect_retries=1)
|
||||||
|
ssh.connect()
|
||||||
|
try:
|
||||||
|
m = AWGManager(ssh)
|
||||||
|
было = len(m._get_used_ips(proto))
|
||||||
|
порт = (s["protocols"][proto] or {}).get("port", "55424")
|
||||||
|
r = m.add_client(proto, ИМЯ, s["host"], порт)
|
||||||
|
cid = r.get("client_id") or r.get("clientId")
|
||||||
|
есть_конфиг = bool(r.get("config"))
|
||||||
|
стало = len(m._get_used_ips(proto))
|
||||||
|
m.remove_client(proto, cid)
|
||||||
|
после = len(m._get_used_ips(proto))
|
||||||
|
finally:
|
||||||
|
ssh.disconnect()
|
||||||
|
|
||||||
|
если_чисто = (после == было)
|
||||||
|
итог["ok" if (есть_конфиг and стало == было + 1) else "сбой"] += 1
|
||||||
|
if not если_чисто:
|
||||||
|
итог["мусор"] += 1
|
||||||
|
print(" %s %-30s пиров %d → %d → %d конфиг=%s" % (
|
||||||
|
"ok" if (есть_конфиг and стало == было + 1 and если_чисто) else "!!",
|
||||||
|
метка, было, стало, после, "есть" if есть_конфиг else "НЕТ"))
|
||||||
|
except Exception as e:
|
||||||
|
итог["сбой"] += 1
|
||||||
|
print(" !! %-30s %s" % (метка, str(e)[:70]))
|
||||||
|
|
||||||
|
print("\nсоздалось и убралось начисто: %d, сбоев: %d, осталось мусора: %d"
|
||||||
|
% (итог["ok"], итог["сбой"], итог["мусор"]))
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
# Установка Amnezia Admin WebUI одной командой (см. README).
|
# Установка Amnezia Admin WebUI одной командой (см. README).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web-PRO}"
|
GITHUB_REPO="${GITHUB_REPO:-PCA/amnezia_web-PRO}"
|
||||||
|
GIT_BASE="${GIT_BASE:-https://git.bobyrev.com}"
|
||||||
BRANCH="${BRANCH:-main}"
|
BRANCH="${BRANCH:-main}"
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/amnezia-admin}"
|
||||||
DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}"
|
DATA_DIR="${DATA_DIR:-/opt/amnezia-admin-data}"
|
||||||
@@ -21,8 +22,9 @@ need_root() {
|
|||||||
|
|
||||||
need_docker() {
|
need_docker() {
|
||||||
command -v docker >/dev/null 2>&1 || {
|
command -v docker >/dev/null 2>&1 || {
|
||||||
echo "Ошибка: нужен Docker."
|
echo "→ Docker не найден — ставлю официальным скриптом get.docker.com..."
|
||||||
exit 1
|
curl -fsSL https://get.docker.com | sh || { echo "Ошибка: не удалось поставить Docker."; exit 1; }
|
||||||
|
systemctl enable --now docker 2>/dev/null || true
|
||||||
}
|
}
|
||||||
docker info >/dev/null 2>&1 || {
|
docker info >/dev/null 2>&1 || {
|
||||||
echo "Ошибка: демон Docker не отвечает."
|
echo "Ошибка: демон Docker не отвечает."
|
||||||
@@ -65,26 +67,24 @@ fi
|
|||||||
|
|
||||||
if [[ "${SKIP_DOWNLOAD:-}" != "1" ]]; then
|
if [[ "${SKIP_DOWNLOAD:-}" != "1" ]]; then
|
||||||
echo "→ Клонирование релиза ${GITHUB_REPO} (${BRANCH})..."
|
echo "→ Клонирование релиза ${GITHUB_REPO} (${BRANCH})..."
|
||||||
echo "→ Скачивание tar.gz с GitHub (вывода может не быть 1–10 мин.; при блокировках задайте зеркало GITHUB_REPO_URL_OVERRIDE или см. CURL_MAX_TIME ниже)."
|
echo "→ Скачивание tar.gz с ${GIT_BASE} (вывода может не быть 1–10 мин.; при блокировках задайте зеркало GITHUB_REPO_URL_OVERRIDE или см. CURL_MAX_TIME ниже)."
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
CURL_OPTS=(
|
CURL_OPTS=(
|
||||||
-fsSL
|
-fsSL
|
||||||
-H 'Cache-Control: no-cache'
|
-H 'Cache-Control: no-cache'
|
||||||
-H 'Pragma: no-cache'
|
-H 'Pragma: no-cache'
|
||||||
--connect-timeout "${CURL_CONNECT_TIMEOUT:-30}"
|
--connect-timeout "${CURL_CONNECT_TIMEOUT:-15}"
|
||||||
--max-time "${CURL_MAX_TIME:-900}"
|
--max-time "${CURL_MAX_TIME:-900}"
|
||||||
--retry "${CURL_RETRY:-2}"
|
--retry "${CURL_RETRY:-2}"
|
||||||
--retry-delay "${CURL_RETRY_DELAY:-5}"
|
--retry-delay "${CURL_RETRY_DELAY:-5}"
|
||||||
--retry-connrefused
|
--retry-connrefused
|
||||||
)
|
)
|
||||||
if [[ "${INSTALL_SCRIPT_VERBOSE:-}" == "1" ]]; then CURL_OPTS+=(--progress-bar); fi
|
if [[ "${INSTALL_SCRIPT_VERBOSE:-}" == "1" ]]; then CURL_OPTS+=(--progress-bar); fi
|
||||||
CURL_URL="${GITHUB_REPO_URL_OVERRIDE:-}"
|
# ponytail: GitHub-аккаунт заблокирован (10.2026), код живёт на git.bobyrev.com.
|
||||||
if [[ -z "${CURL_URL}" ]]; then
|
CURL_URL="${GITHUB_REPO_URL_OVERRIDE:-${GIT_BASE}/${GITHUB_REPO}/archive/${BRANCH}.tar.gz}"
|
||||||
CURL_URL="https://github.com/${GITHUB_REPO}/archive/refs/heads/${BRANCH}.tar.gz"
|
|
||||||
fi
|
|
||||||
if ! curl "${CURL_OPTS[@]}" "${CURL_URL}" | tar xz -C "${TMP}"; then
|
if ! curl "${CURL_OPTS[@]}" "${CURL_URL}" | tar xz -C "${TMP}"; then
|
||||||
echo "Ошибка: не удалось скачать или распаковать архив (${CURL_URL})."
|
echo "Ошибка: не удалось скачать или распаковать архив (${CURL_URL})."
|
||||||
echo "Подсказка: проверьте доступ к github.com, branch/repo, при необходимости export GITHUB_REPO_URL_OVERRIDE='…' или INSTALL_SCRIPT_VERBOSE=1."
|
echo "Подсказка: проверьте доступ к git.bobyrev.com, при необходимости export GITHUB_REPO_URL_OVERRIDE='…' или INSTALL_SCRIPT_VERBOSE=1."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "→ Перенос распакованного дерева в ${INSTALL_DIR}…"
|
echo "→ Перенос распакованного дерева в ${INSTALL_DIR}…"
|
||||||
@@ -161,7 +161,8 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | sort || true)"
|
# Контейнеры, развёрнутые самой панелью (/opt/amnezia-instances/<имя>), панель знает сама — в AWG_PROFILES их не кладём.
|
||||||
|
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | while read -r __n; do [[ -d "/opt/amnezia-instances/${__n}" ]] || echo "${__n}"; done | sort || true)"
|
||||||
__awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
__awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||||
__profile_container_count=0
|
__profile_container_count=0
|
||||||
if [[ -n "${AWG_PROFILES:-}" ]]; then
|
if [[ -n "${AWG_PROFILES:-}" ]]; then
|
||||||
@@ -282,20 +283,6 @@ if [[ -n "${BOOT_PW}" ]]; then
|
|||||||
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
RUN_ENV+=( -e "ADMIN_PASSWORD=${BOOT_PW}" )
|
||||||
fi
|
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)"
|
IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||||
|
|
||||||
@@ -367,4 +354,4 @@ if [[ -f "${PASS_FILE}" ]]; then
|
|||||||
echo "Первый пароль: $(cat "${PASS_FILE}")"
|
echo "Первый пароль: $(cat "${PASS_FILE}")"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Удаление: curl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/${BRANCH}/scripts/uninstall.sh | sudo bash"
|
echo "Удаление: curl -fsSL ${GIT_BASE}/${GITHUB_REPO}/raw/branch/${BRANCH}/scripts/uninstall.sh | sudo bash"
|
||||||
|
|||||||
601
server.js
601
server.js
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "child_process";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import dns from "dns";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs";
|
import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs";
|
||||||
|
|
||||||
@@ -50,36 +51,6 @@ function resolveUiHidden() {
|
|||||||
|
|
||||||
const UI_HIDDEN = resolveUiHidden();
|
const UI_HIDDEN = resolveUiHidden();
|
||||||
|
|
||||||
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
|
|
||||||
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
|
|
||||||
const COMMUNITY_UPGRADE_URL =
|
|
||||||
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
|
|
||||||
"https://github.com/andrey271192/amnezia_web-PRO";
|
|
||||||
const COMMUNITY_UPGRADE_PITCH =
|
|
||||||
process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
|
|
||||||
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка открыта в публичном репозитории amnezia_web-PRO.";
|
|
||||||
|
|
||||||
function editionPayload() {
|
|
||||||
return {
|
|
||||||
tier: IS_COMMUNITY ? "community" : "pro",
|
|
||||||
readOnlyClients: IS_COMMUNITY,
|
|
||||||
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
|
|
||||||
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
|
|
||||||
showDebugWg: !IS_COMMUNITY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectiveUiHidden() {
|
|
||||||
if (!IS_COMMUNITY) return { ...UI_HIDDEN };
|
|
||||||
return {
|
|
||||||
users: UI_HIDDEN.users,
|
|
||||||
warp: true,
|
|
||||||
cascade: true,
|
|
||||||
mtproto: UI_HIDDEN.mtproto,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function parseProfilesFromEnv() {
|
function parseProfilesFromEnv() {
|
||||||
const raw = process.env.AWG_PROFILES?.trim();
|
const raw = process.env.AWG_PROFILES?.trim();
|
||||||
const fallback = () => {
|
const fallback = () => {
|
||||||
@@ -87,6 +58,7 @@ function parseProfilesFromEnv() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "awg",
|
id: "awg",
|
||||||
|
isDefault: true,
|
||||||
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
|
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
|
||||||
container: process.env.AWG_CONTAINER || "amnezia-awg",
|
container: process.env.AWG_CONTAINER || "amnezia-awg",
|
||||||
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
|
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
|
||||||
@@ -137,25 +109,36 @@ function parseProfilesFromEnv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ENV_PROFILES = parseProfilesFromEnv();
|
const ENV_PROFILES = parseProfilesFromEnv();
|
||||||
|
let defaultProfileMissing = false; // set by createRuntime when the default profile resolves to nothing
|
||||||
|
|
||||||
const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances";
|
const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances";
|
||||||
const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`;
|
const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`;
|
||||||
const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`;
|
const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`;
|
||||||
|
|
||||||
const INSTANCE_VARIANTS = {
|
const INSTANCE_VARIANTS = {
|
||||||
|
awg31: {
|
||||||
|
label: "AmneziaWG 3.1",
|
||||||
|
desc: "Новейшая версия: защита заголовков, случайные хвосты пакетов. Нужно свежее приложение Amnezia.",
|
||||||
|
iface: "awg0", binary: "awg",
|
||||||
|
},
|
||||||
|
awg3: {
|
||||||
|
label: "AmneziaWG 3.0",
|
||||||
|
desc: "Защита заголовков (HeaderProtectionKey), паддинг. Приложение Amnezia 4.8.x и новее.",
|
||||||
|
iface: "awg0", binary: "awg",
|
||||||
|
},
|
||||||
awg2: {
|
awg2: {
|
||||||
label: "AmneziaWG 2.0",
|
label: "AmneziaWG 2.0",
|
||||||
desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).",
|
desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).",
|
||||||
iface: "awg0", binary: "awg",
|
iface: "awg0", binary: "awg",
|
||||||
},
|
},
|
||||||
awg: {
|
awg: {
|
||||||
label: "AmneziaWG",
|
label: "AmneziaWG 1.x (роутеры)",
|
||||||
desc: "Версия протокола на основе awg-go. Обфускация S1, S2.",
|
desc: "Обфускация Jc/S1/S2/H1–H4 без S3/S4 — для Keenetic, OpenWrt и старых приложений.",
|
||||||
iface: "awg0", binary: "awg",
|
iface: "awg0", binary: "awg",
|
||||||
},
|
},
|
||||||
legacy: {
|
legacy: {
|
||||||
label: "AmneziaWG Legacy",
|
label: "WireGuard (Legacy)",
|
||||||
desc: "Оригинальная версия на ядре WireGuard. Совместима с клиентами старых версий.",
|
desc: "Чистый WireGuard без обфускации — любой WG-клиент и любые роутеры. Легко блокируется DPI.",
|
||||||
iface: "wg0", binary: "wg",
|
iface: "wg0", binary: "wg",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -219,10 +202,16 @@ function profileIdentityKey(p) {
|
|||||||
// Effective profile set = env profiles + managed instances (deduped by id and target files).
|
// Effective profile set = env profiles + managed instances (deduped by id and target files).
|
||||||
function getProfiles() {
|
function getProfiles() {
|
||||||
const managed = loadManagedProfiles();
|
const managed = loadManagedProfiles();
|
||||||
|
const managedContainers = new Set(managed.map((m) => m.container));
|
||||||
const seenIds = new Set();
|
const seenIds = new Set();
|
||||||
const seenTargets = new Set();
|
const seenTargets = new Set();
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const p of [...ENV_PROFILES, ...managed]) {
|
// install.sh used to put panel-managed containers into AWG_PROFILES too: the env copy then
|
||||||
|
// shadowed the managed one (wrong id, no variant). Managed instance wins.
|
||||||
|
const envProfiles = ENV_PROFILES.filter((p) => !managedContainers.has(p.container));
|
||||||
|
// Default profile (no AWG_PROFILES) that found no container: hide it once the panel has its own instances.
|
||||||
|
const env = managed.length && defaultProfileMissing ? envProfiles.filter((p) => !p.isDefault) : envProfiles;
|
||||||
|
for (const p of [...env, ...managed]) {
|
||||||
const id = String(p.id || "").trim();
|
const id = String(p.id || "").trim();
|
||||||
const target = profileIdentityKey(p);
|
const target = profileIdentityKey(p);
|
||||||
if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue;
|
if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue;
|
||||||
@@ -438,23 +427,6 @@ function requireAuthOrExportToken(req, res, next) {
|
|||||||
requireAuth(req, res, next);
|
requireAuth(req, res, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectCommunityProOnly(res) {
|
|
||||||
res.status(403).json({
|
|
||||||
error:
|
|
||||||
"Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
|
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireProTier(_req, res, next) {
|
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rejectCommunityProOnly(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimeFromExportRequest(req) {
|
function runtimeFromExportRequest(req) {
|
||||||
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
|
||||||
const bodyPid =
|
const bodyPid =
|
||||||
@@ -546,6 +518,30 @@ async function dockerRestartContainer(container) {
|
|||||||
throw new Error("Контейнер не ответил после restart");
|
throw new Error("Контейнер не ответил после restart");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// awg-quick brings up both AmneziaWG and plain WireGuard configs; old images only have wg-quick.
|
||||||
|
const QUICK_SH = "$(command -v awg-quick || echo wg-quick)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upstream for a real cascade: a client .conf of ANOTHER server (AWG or WG) becomes the "warp"
|
||||||
|
* interface of this instance. Routing of selected clients reuses the WARP machinery.
|
||||||
|
*/
|
||||||
|
function buildUpstreamConf(text) {
|
||||||
|
const sections = parseWireGuardSections(text);
|
||||||
|
const iface = sections.interface || {};
|
||||||
|
const peer = sections.peer || {};
|
||||||
|
if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) {
|
||||||
|
throw new Error("Нужен клиентский конфиг другого сервера: [Interface] с PrivateKey и [Peer] с PublicKey и Endpoint.");
|
||||||
|
}
|
||||||
|
const lines = ["[Interface]", `PrivateKey = ${iface.PrivateKey}`];
|
||||||
|
if (iface.Address) lines.push(`Address = ${String(iface.Address).split(",")[0].trim()}`);
|
||||||
|
for (const k of AWG_OBF_KEYS) if (iface[k]) lines.push(`${k} = ${iface[k]}`);
|
||||||
|
lines.push(`MTU = ${iface.MTU || 1280}`, "Table = off", "", "[Peer]", `PublicKey = ${peer.PublicKey}`);
|
||||||
|
if (peer.PresharedKey) lines.push(`PresharedKey = ${peer.PresharedKey}`);
|
||||||
|
lines.push("AllowedIPs = 0.0.0.0/0", `Endpoint = ${peer.Endpoint}`, "PersistentKeepalive = 25", "");
|
||||||
|
for (const l of lines) if (/['`$\\]/.test(l)) throw new Error("Недопустимые символы в конфиге.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
async function warpFileExists(rt, remotePath) {
|
async function warpFileExists(rt, remotePath) {
|
||||||
try {
|
try {
|
||||||
await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]);
|
await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]);
|
||||||
@@ -637,7 +633,7 @@ function buildWarpBootBlock(warpConf, ips) {
|
|||||||
return (
|
return (
|
||||||
"# --- WARP-MANAGER BEGIN ---\n\n" +
|
"# --- WARP-MANAGER BEGIN ---\n\n" +
|
||||||
`if [ -f '${warpConf}' ]; then\n` +
|
`if [ -f '${warpConf}' ]; then\n` +
|
||||||
` wg-quick up '${warpConf}' || true\n` +
|
` ${QUICK_SH} up '${warpConf}' || true\n` +
|
||||||
` sleep 3\n` +
|
` sleep 3\n` +
|
||||||
`fi\n\n` +
|
`fi\n\n` +
|
||||||
routing +
|
routing +
|
||||||
@@ -745,7 +741,7 @@ async function warpSummaryForRt(rt) {
|
|||||||
let wgShowWarp = "";
|
let wgShowWarp = "";
|
||||||
if (installed && running) {
|
if (installed && running) {
|
||||||
try {
|
try {
|
||||||
wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true");
|
wgShowWarp = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
|
||||||
} catch {
|
} catch {
|
||||||
wgShowWarp = "";
|
wgShowWarp = "";
|
||||||
}
|
}
|
||||||
@@ -800,7 +796,8 @@ async function containerHasFile(name, filePath) {
|
|||||||
// Lets the panel work even if AWG_CONTAINER points at the wrong name
|
// Lets the panel work even if AWG_CONTAINER points at the wrong name
|
||||||
// (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2").
|
// (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2").
|
||||||
async function discoverAwgContainer(confPath, preferredName) {
|
async function discoverAwgContainer(confPath, preferredName) {
|
||||||
const names = await listRunningContainerNames();
|
const managed = new Set(loadManagedProfiles().map((m) => m.container));
|
||||||
|
const names = (await listRunningContainerNames()).filter((n) => n === preferredName || !managed.has(n));
|
||||||
const score = (n) => {
|
const score = (n) => {
|
||||||
if (preferredName && n === preferredName) return 0;
|
if (preferredName && n === preferredName) return 0;
|
||||||
if (/^amnezia-?awg/i.test(n)) return 1;
|
if (/^amnezia-?awg/i.test(n)) return 1;
|
||||||
@@ -852,6 +849,7 @@ function createRuntime(profile) {
|
|||||||
if (profile.container) {
|
if (profile.container) {
|
||||||
for (const candidate of confCandidates) {
|
for (const candidate of confCandidates) {
|
||||||
if (await containerHasFile(profile.container, candidate)) {
|
if (await containerHasFile(profile.container, candidate)) {
|
||||||
|
if (profile.isDefault) defaultProfileMissing = false;
|
||||||
resolvedContainer = profile.container;
|
resolvedContainer = profile.container;
|
||||||
return resolvedContainer;
|
return resolvedContainer;
|
||||||
}
|
}
|
||||||
@@ -862,6 +860,7 @@ function createRuntime(profile) {
|
|||||||
found = await discoverAwgContainer(candidate, profile.container);
|
found = await discoverAwgContainer(candidate, profile.container);
|
||||||
if (found) break;
|
if (found) break;
|
||||||
}
|
}
|
||||||
|
if (profile.isDefault) defaultProfileMissing = !found;
|
||||||
if (found) {
|
if (found) {
|
||||||
resolvedContainer = found;
|
resolvedContainer = found;
|
||||||
if (found !== profile.container) {
|
if (found !== profile.container) {
|
||||||
@@ -996,7 +995,7 @@ function createRuntime(profile) {
|
|||||||
const activeIface = ifaceFromConfPath(activeConfPath, iface);
|
const activeIface = ifaceFromConfPath(activeConfPath, iface);
|
||||||
const activeBinary = await resolveWgBinary();
|
const activeBinary = await resolveWgBinary();
|
||||||
await dockerExec(
|
await dockerExec(
|
||||||
`wg-quick strip '${activeConfPath}' > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf`
|
`(awg-quick strip '${activeConfPath}' 2>/dev/null || wg-quick strip '${activeConfPath}') > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,26 +1185,45 @@ function defaultPeerClientName(peer) {
|
|||||||
return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`;
|
return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */
|
|
||||||
const AWG_EXPORT_DEFAULTS = {
|
// Client-side obfuscation keys, in the order Amnezia writes them. 3.x keys must match the server.
|
||||||
Jc: "3",
|
const AWG_OBF_KEYS = [
|
||||||
Jmin: "10",
|
"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5",
|
||||||
Jmax: "30",
|
"HeaderProtectionKey", "ContentPaddingAddition", "RekeyAfterTime", "RekeyTimeout", "RejectAfterTime",
|
||||||
S1: "15",
|
"KeepaliveTimeout", "MaxHandshakeAttempts", "RandomTrailers", "DisableCookies",
|
||||||
S2: "18",
|
];
|
||||||
S3: "20",
|
const AWG_OBF_ALIASES = {
|
||||||
S4: "23",
|
Jc: ["junk_packet_count", "junkPacketCount"], Jmin: ["junk_packet_min_size", "junkPacketMinSize"],
|
||||||
H1: "1020325451",
|
Jmax: ["junk_packet_max_size", "junkPacketMaxSize"], S1: ["init_packet_junk_size", "initPacketJunkSize"],
|
||||||
H2: "3288052141",
|
S2: ["response_packet_junk_size", "responsePacketJunkSize"], S3: ["cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize"],
|
||||||
H3: "1766607858",
|
S4: ["transport_packet_junk_size", "transportPacketJunkSize"], H1: ["init_packet_magic_header", "initPacketMagicHeader"],
|
||||||
H4: "2528465083",
|
H2: ["response_packet_magic_header", "responsePacketMagicHeader"], H3: ["underload_packet_magic_header", "underloadPacketMagicHeader"],
|
||||||
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
|
H4: ["transport_packet_magic_header", "transportPacketMagicHeader"],
|
||||||
I2: "",
|
I1: ["special_junk_1", "specialJunk1"], I2: ["special_junk_2", "specialJunk2"], I3: ["special_junk_3", "specialJunk3"],
|
||||||
I3: "",
|
I4: ["special_junk_4", "specialJunk4"], I5: ["special_junk_5", "specialJunk5"],
|
||||||
I4: "",
|
HeaderProtectionKey: ["header_protection_key"],
|
||||||
I5: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obfuscation lines for a client .conf. The server [Interface] is the source of truth: a key the
|
||||||
|
* server does not have (e.g. S3/S4 on AWG 1.x) must not appear in the client, otherwise the
|
||||||
|
* handshake passes but data packets are dropped. Keys only in last_config (I1..I5 written by the
|
||||||
|
* Amnezia app) are kept. Returns "" for plain WireGuard servers.
|
||||||
|
*/
|
||||||
|
function awgObfuscationLines(lc, ifaceMap) {
|
||||||
|
if (!ifaceMap.Jc && !pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount")) return "";
|
||||||
|
const lines = [];
|
||||||
|
for (const k of AWG_OBF_KEYS) {
|
||||||
|
const isServerKey = !/^I\d$/.test(k);
|
||||||
|
let v = isServerKey ? ifaceMap[k] : undefined;
|
||||||
|
if (v == null || String(v).trim() === "") v = pickLc(lc, k, ...(AWG_OBF_ALIASES[k] || []));
|
||||||
|
if (!isServerKey && (v == null || String(v).trim() === "")) v = ifaceMap[k];
|
||||||
|
if (isServerKey && ifaceMap.Jc && (ifaceMap[k] == null || ifaceMap[k] === "")) v = undefined;
|
||||||
|
if (v != null && String(v).trim() !== "") lines.push(`${k} = ${String(v).trim()}`);
|
||||||
|
}
|
||||||
|
return lines.length ? `${lines.join("\n")}\n` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function parseLastConfigFromClientRow(row) {
|
function parseLastConfigFromClientRow(row) {
|
||||||
const ud = row?.userData;
|
const ud = row?.userData;
|
||||||
if (!ud || typeof ud !== "object") return null;
|
if (!ud || typeof ud !== "object") return null;
|
||||||
@@ -1490,8 +1508,28 @@ function tunnelClientIpv4(peer, lc, row) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel settings editable from the UI (domain for client Endpoint etc.).
|
||||||
|
const SETTINGS_FILE = path.join(process.env.DATA_DIR || "/data", "settings.json");
|
||||||
|
function loadPanelSettings() {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
||||||
|
return o && typeof o === "object" ? o : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function savePanelSettings(next) {
|
||||||
|
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
|
||||||
|
}
|
||||||
|
/** Domain/IP set in the UI wins over CLIENT_CONFIG_ENDPOINT (installer puts the raw IP there). */
|
||||||
|
function configuredEndpointHost() {
|
||||||
|
const ui = String(loadPanelSettings().endpointHost || "").trim();
|
||||||
|
return ui || process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveExportEndpointHost(lc, req) {
|
function resolveExportEndpointHost(lc, req) {
|
||||||
const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim();
|
const env = configuredEndpointHost();
|
||||||
if (env) return env;
|
if (env) return env;
|
||||||
const hn = pickLc(lc, "hostName", "hostname", "host");
|
const hn = pickLc(lc, "hostName", "hostname", "host");
|
||||||
if (hn && String(hn).trim()) return String(hn).trim();
|
if (hn && String(hn).trim()) return String(hn).trim();
|
||||||
@@ -1546,70 +1584,11 @@ async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) {
|
|||||||
const mtuVal = pickLc(lc, "mtu", "MTU");
|
const mtuVal = pickLc(lc, "mtu", "MTU");
|
||||||
const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
|
const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
|
||||||
|
|
||||||
if (activeBinary === "awg") {
|
|
||||||
const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc);
|
|
||||||
const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin);
|
|
||||||
const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax);
|
|
||||||
const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1);
|
|
||||||
const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2);
|
|
||||||
const S3 = String(
|
|
||||||
pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3,
|
|
||||||
);
|
|
||||||
const S4 = String(
|
|
||||||
pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4,
|
|
||||||
);
|
|
||||||
const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1);
|
|
||||||
const H2 = String(
|
|
||||||
pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2,
|
|
||||||
);
|
|
||||||
const H3 = String(
|
|
||||||
pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3,
|
|
||||||
);
|
|
||||||
const H4 = String(
|
|
||||||
pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4,
|
|
||||||
);
|
|
||||||
const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1);
|
|
||||||
const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2);
|
|
||||||
const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3);
|
|
||||||
const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4);
|
|
||||||
const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5);
|
|
||||||
const iLines = [
|
|
||||||
["I1", I1], ["I2", I2], ["I3", I3], ["I4", I4], ["I5", I5],
|
|
||||||
]
|
|
||||||
.filter(([, v]) => String(v ?? "").trim() !== "")
|
|
||||||
.map(([k, v]) => `${k} = ${v}`)
|
|
||||||
.join("\n");
|
|
||||||
const iBlock = iLines ? `${iLines}\n` : "";
|
|
||||||
|
|
||||||
return `[Interface]
|
return `[Interface]
|
||||||
Address = ${tunnelIp}/32
|
Address = ${tunnelIp}/32
|
||||||
DNS = ${dns1}, ${dns2}
|
DNS = ${dns1}, ${dns2}
|
||||||
PrivateKey = ${priv.trim()}
|
PrivateKey = ${priv.trim()}
|
||||||
Jc = ${Jc}
|
${awgObfuscationLines(lc, ifaceMap)}${mtuLine}[Peer]
|
||||||
Jmin = ${Jmin}
|
|
||||||
Jmax = ${Jmax}
|
|
||||||
S1 = ${S1}
|
|
||||||
S2 = ${S2}
|
|
||||||
S3 = ${S3}
|
|
||||||
S4 = ${S4}
|
|
||||||
H1 = ${H1}
|
|
||||||
H2 = ${H2}
|
|
||||||
H3 = ${H3}
|
|
||||||
H4 = ${H4}
|
|
||||||
${iBlock}${mtuLine}[Peer]
|
|
||||||
PublicKey = ${String(serverPub).trim()}
|
|
||||||
PresharedKey = ${String(psk).trim()}
|
|
||||||
AllowedIPs = ${peerAllowed}
|
|
||||||
Endpoint = ${endpointHost}:${port}
|
|
||||||
PersistentKeepalive = ${keepAlive}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `[Interface]
|
|
||||||
Address = ${tunnelIp}/32
|
|
||||||
DNS = ${dns1}, ${dns2}
|
|
||||||
PrivateKey = ${priv.trim()}
|
|
||||||
${mtuLine}[Peer]
|
|
||||||
PublicKey = ${String(serverPub).trim()}
|
PublicKey = ${String(serverPub).trim()}
|
||||||
PresharedKey = ${String(psk).trim()}
|
PresharedKey = ${String(psk).trim()}
|
||||||
AllowedIPs = ${peerAllowed}
|
AllowedIPs = ${peerAllowed}
|
||||||
@@ -1652,7 +1631,7 @@ async function awgGenKeypair(rt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function obfuscationFieldsFromServerHead(ifaceMap) {
|
function obfuscationFieldsFromServerHead(ifaceMap) {
|
||||||
const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
|
const keys = AWG_OBF_KEYS;
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
const v = ifaceMap[k];
|
const v = ifaceMap[k];
|
||||||
@@ -2080,9 +2059,6 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
|
|||||||
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
|
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
|
|
||||||
}
|
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -2128,15 +2104,6 @@ app.get("/api/server-time", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
res.json({
|
|
||||||
hostTimeSync: false,
|
|
||||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
|
||||||
serverClockTimeZone: resolveServerClockTimeZone(),
|
|
||||||
communityBlocked: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json({
|
res.json({
|
||||||
hostTimeSync: hostTimeSyncConfigured(),
|
hostTimeSync: hostTimeSyncConfigured(),
|
||||||
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
|
||||||
@@ -2154,7 +2121,7 @@ function mtprotoLogsTailPayload(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const snap = mtprotoSnapshot(hostFromRequest(req));
|
const snap = mtprotoSnapshot(hostFromRequest(req));
|
||||||
@@ -2170,21 +2137,21 @@ app.get("/api/mtproto/status", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/mtproto/logs", requireAuth, (req, res) => {
|
app.get("/api/mtproto/logs", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
res.json(mtprotoLogsTailPayload(req));
|
res.json(mtprotoLogsTailPayload(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/mtproto/tail", requireAuth, (req, res) => {
|
app.get("/api/mtproto/tail", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
res.json(mtprotoLogsTailPayload(req));
|
res.json(mtprotoLogsTailPayload(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) {
|
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) {
|
||||||
@@ -2280,7 +2247,7 @@ app.post("/api/mtproto/install", requireAuth, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000);
|
const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000);
|
||||||
@@ -2294,7 +2261,7 @@ app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
||||||
if (effectiveUiHidden().mtproto) {
|
if (UI_HIDDEN.mtproto) {
|
||||||
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
return res.status(403).json({ error: MSG_UI_MTProto_OFF });
|
||||||
}
|
}
|
||||||
const snap = mtprotoSnapshot();
|
const snap = mtprotoSnapshot();
|
||||||
@@ -2309,7 +2276,7 @@ app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/sync-host-time", requireAuth, async (req, res) => {
|
||||||
if (!hostTimeSyncConfigured()) {
|
if (!hostTimeSyncConfigured()) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
error:
|
error:
|
||||||
@@ -2341,7 +2308,7 @@ app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/host-setup", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -2385,6 +2352,202 @@ app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Xray VLESS-Reality ----------
|
||||||
|
// Layout matches the official Amnezia client (xray_*.key + clientsTable), so the app sees the same server.
|
||||||
|
const XRAY_CONTAINER = "amnezia-xray";
|
||||||
|
const XRAY_IMAGE = "amnezia-xray";
|
||||||
|
const XRAY_DIR = "/opt/amnezia/xray";
|
||||||
|
const XRAY_VERSION = "v26.3.27";
|
||||||
|
const XRAY_DOCKERFILE = `FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache curl unzip bash openssl dumb-init && \\
|
||||||
|
curl -fsSL -o /root/xray.zip https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip && \\
|
||||||
|
unzip /root/xray.zip xray -d /usr/bin/ && chmod +x /usr/bin/xray && rm /root/xray.zip && mkdir -p ${XRAY_DIR}
|
||||||
|
ENTRYPOINT ["dumb-init", "/usr/bin/xray", "-config", "${XRAY_DIR}/server.json"]
|
||||||
|
`;
|
||||||
|
|
||||||
|
function xrayExec(script) {
|
||||||
|
return dockerExecStdin(XRAY_CONTAINER, script);
|
||||||
|
}
|
||||||
|
function xrayShellQuote(s) {
|
||||||
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
async function xrayReadFile(name) {
|
||||||
|
try {
|
||||||
|
return (await xrayExec(`cat ${XRAY_DIR}/${name} 2>/dev/null`)).stdout;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function xrayWriteFile(name, text) {
|
||||||
|
await execDocker(["exec", "-i", XRAY_CONTAINER, "sh", "-c", `cat > ${XRAY_DIR}/${name}`], text);
|
||||||
|
}
|
||||||
|
function xrayInspect() {
|
||||||
|
const r = dockerSpawnSync(["inspect", "--format", "{{.State.Running}}", XRAY_CONTAINER], 15_000);
|
||||||
|
return { installed: r.code === 0, running: r.stdout.trim() === "true" };
|
||||||
|
}
|
||||||
|
async function xrayState() {
|
||||||
|
const server = JSON.parse((await xrayReadFile("server.json")) || "{}");
|
||||||
|
const ib = (server.inbounds || []).find((i) => i.protocol === "vless");
|
||||||
|
if (!ib) throw new Error("В server.json нет VLESS-входа.");
|
||||||
|
let table = [];
|
||||||
|
try {
|
||||||
|
table = JSON.parse((await xrayReadFile("clientsTable")) || "[]");
|
||||||
|
} catch {}
|
||||||
|
if (!Array.isArray(table)) table = [];
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
ib,
|
||||||
|
table,
|
||||||
|
pub: (await xrayReadFile("xray_public.key")).trim(),
|
||||||
|
shortId: (await xrayReadFile("xray_short_id.key")).trim() || ib.streamSettings?.realitySettings?.shortIds?.[0] || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function xraySave(st) {
|
||||||
|
// Enabled = present in server.json; disabled clients live only in clientsTable.
|
||||||
|
st.ib.settings.clients = st.table
|
||||||
|
.filter((r) => !r.userData?.disabled)
|
||||||
|
.map((r) => ({ id: r.clientId, flow: "xtls-rprx-vision" }));
|
||||||
|
await xrayWriteFile("server.json", JSON.stringify(st.server, null, 2));
|
||||||
|
await xrayWriteFile("clientsTable", JSON.stringify(st.table, null, 2));
|
||||||
|
await execDocker(["restart", XRAY_CONTAINER]);
|
||||||
|
}
|
||||||
|
function xrayLink(st, row, host) {
|
||||||
|
const rs = st.ib.streamSettings.realitySettings;
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
type: "tcp",
|
||||||
|
security: "reality",
|
||||||
|
pbk: st.pub,
|
||||||
|
fp: "chrome",
|
||||||
|
sni: rs.serverNames[0],
|
||||||
|
sid: st.shortId,
|
||||||
|
spx: "/",
|
||||||
|
flow: "xtls-rprx-vision",
|
||||||
|
encryption: "none",
|
||||||
|
});
|
||||||
|
const name = encodeURIComponent(row.userData?.clientName || "client");
|
||||||
|
return `vless://${row.clientId}@${host}:${st.ib.port}?${q}#${name}`;
|
||||||
|
}
|
||||||
|
function xrayHost(req) {
|
||||||
|
return configuredEndpointHost() || hostFromRequest(req) || "SERVER_IP";
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/api/xray/status", requireAuth, async (req, res) => {
|
||||||
|
const s = xrayInspect();
|
||||||
|
if (!s.running) return res.json({ ...s, clients: [] });
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const host = xrayHost(req);
|
||||||
|
res.json({
|
||||||
|
...s,
|
||||||
|
port: st.ib.port,
|
||||||
|
sni: st.ib.streamSettings?.realitySettings?.serverNames?.[0] || "",
|
||||||
|
clients: st.table.map((r) => ({
|
||||||
|
id: r.clientId,
|
||||||
|
name: r.userData?.clientName || r.clientId,
|
||||||
|
enabled: !r.userData?.disabled,
|
||||||
|
created: r.userData?.creationDate || "",
|
||||||
|
link: xrayLink(st, r, host),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ ...s, clients: [], error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/install", requireAuth, async (req, res) => {
|
||||||
|
const port = Number.parseInt(String(req.body?.port || "443"), 10);
|
||||||
|
const sni = String(req.body?.sni || "www.google.com").trim();
|
||||||
|
if (!(port > 0 && port < 65536)) return res.status(400).json({ error: "Неверный порт." });
|
||||||
|
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(sni)) return res.status(400).json({ error: "Неверный сайт-маскировка (SNI)." });
|
||||||
|
if (xrayInspect().installed) return res.status(409).json({ error: "Xray уже установлен. Сначала удалите его." });
|
||||||
|
const busy = dockerSpawnSync(["ps", "--format", "{{.Names}} {{.Ports}}"], 15_000).stdout
|
||||||
|
.split("\n")
|
||||||
|
.find((l) => new RegExp(`:${port}->\\d+/tcp`).test(l));
|
||||||
|
if (busy) return res.status(409).json({ error: `TCP-порт ${port} занят: ${busy.split(" ")[0]}` });
|
||||||
|
try {
|
||||||
|
await execDocker(["build", "-t", XRAY_IMAGE, "-"], XRAY_DOCKERFILE);
|
||||||
|
const kp = (await execDocker(["run", "--rm", "--entrypoint", "/usr/bin/xray", XRAY_IMAGE, "x25519"])).stdout;
|
||||||
|
const priv = (kp.match(/Private\s*key:\s*(\S+)/i) || kp.match(/PrivateKey:\s*(\S+)/i) || [])[1];
|
||||||
|
const pub = (kp.match(/(?:Public\s*key|Password)[^:\n]*:\s*(\S+)/i) || [])[1];
|
||||||
|
if (!priv || !pub) throw new Error(`Не удалось получить ключи x25519: ${kp}`);
|
||||||
|
const shortId = crypto.randomBytes(8).toString("hex");
|
||||||
|
const server = {
|
||||||
|
log: { loglevel: "error" },
|
||||||
|
inbounds: [
|
||||||
|
{
|
||||||
|
port,
|
||||||
|
protocol: "vless",
|
||||||
|
settings: { clients: [], decryption: "none" },
|
||||||
|
streamSettings: {
|
||||||
|
network: "tcp",
|
||||||
|
security: "reality",
|
||||||
|
realitySettings: { dest: `${sni}:443`, serverNames: [sni], privateKey: priv, shortIds: [shortId] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbounds: [{ protocol: "freedom" }],
|
||||||
|
};
|
||||||
|
const files = {
|
||||||
|
"server.json": JSON.stringify(server, null, 2),
|
||||||
|
"xray_private.key": `${priv}\n`,
|
||||||
|
"xray_public.key": `${pub}\n`,
|
||||||
|
"xray_short_id.key": `${shortId}\n`,
|
||||||
|
"xray_uuid.key": "\n",
|
||||||
|
clientsTable: "[]",
|
||||||
|
};
|
||||||
|
const script = ["set -e", `mkdir -p /x`]
|
||||||
|
.concat(Object.entries(files).map(([n, t]) => `printf '%s' ${xrayShellQuote(t)} > /x/${n}`))
|
||||||
|
.join("\n");
|
||||||
|
await execDocker(["run", "--rm", "-i", "-v", `${XRAY_DIR}:/x`, "--entrypoint", "sh", XRAY_IMAGE, "-s"], script);
|
||||||
|
await execDocker([
|
||||||
|
"run", "-d", "--restart", "always", "--name", XRAY_CONTAINER,
|
||||||
|
"-p", `${port}:${port}/tcp`, "-v", `${XRAY_DIR}:${XRAY_DIR}`, XRAY_IMAGE,
|
||||||
|
]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/remove", requireAuth, async (_req, res) => {
|
||||||
|
dockerSpawnSync(["rm", "-f", XRAY_CONTAINER], 60_000);
|
||||||
|
// ponytail: keys and clientsTable stay in /opt/amnezia/xray on the host; a reinstall regenerates them.
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/clients/create", requireAuth, async (req, res) => {
|
||||||
|
const name = String(req.body?.name || "").trim().slice(0, 64);
|
||||||
|
if (!name) return res.status(400).json({ error: "Укажите имя клиента." });
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const row = { clientId: crypto.randomUUID(), userData: { clientName: name, creationDate: new Date().toString() } };
|
||||||
|
st.table.push(row);
|
||||||
|
await xraySave(st);
|
||||||
|
res.json({ ok: true, link: xrayLink(st, row, xrayHost(req)) });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/xray/clients/update", requireAuth, async (req, res) => {
|
||||||
|
const id = String(req.body?.id || "");
|
||||||
|
const action = String(req.body?.action || "");
|
||||||
|
try {
|
||||||
|
const st = await xrayState();
|
||||||
|
const row = st.table.find((r) => r.clientId === id);
|
||||||
|
if (!row) return res.status(404).json({ error: "Клиент не найден." });
|
||||||
|
if (action === "delete") st.table = st.table.filter((r) => r !== row);
|
||||||
|
else if (action === "enable" || action === "disable") {
|
||||||
|
row.userData = { ...row.userData, disabled: action === "disable" };
|
||||||
|
if (!row.userData.disabled) delete row.userData.disabled;
|
||||||
|
} else return res.status(400).json({ error: "Неизвестное действие." });
|
||||||
|
await xraySave(st);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/login", (req, res) => {
|
app.post("/api/login", (req, res) => {
|
||||||
const pw = req.body?.password;
|
const pw = req.body?.password;
|
||||||
if (typeof pw !== "string" || !pw) {
|
if (typeof pw !== "string" || !pw) {
|
||||||
@@ -2427,14 +2590,43 @@ app.post("/api/change-password", requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
|
res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/settings", requireAuth, (_req, res) => {
|
||||||
|
const st = loadPanelSettings();
|
||||||
|
res.json({
|
||||||
|
endpointHost: st.endpointHost || "",
|
||||||
|
envEndpoint: process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "",
|
||||||
|
effectiveEndpoint: configuredEndpointHost(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/settings", requireAuth, async (req, res) => {
|
||||||
|
const raw = String(req.body?.endpointHost ?? "").trim();
|
||||||
|
let host = "";
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
host = assertCascadeEndpointHost(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: String(e.message || e) });
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9.-]+$/.test(host) && !/^[0-9a-fA-F:]+$/.test(host)) {
|
||||||
|
return res.status(400).json({ error: "Домен или IP: только латиница, цифры, точки и дефисы." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resolved = null;
|
||||||
|
if (host && !/^[\d.]+$/.test(host) && host.includes(".")) {
|
||||||
|
try {
|
||||||
|
resolved = (await dns.promises.lookup(host, { all: true })).map((a) => a.address);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: `Домен ${host} не резолвится. Создайте A-запись на IP этого сервера и повторите.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savePanelSettings({ ...loadPanelSettings(), endpointHost: host });
|
||||||
|
res.json({ ok: true, endpointHost: host, resolved });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/protocols", requireAuth, (req, res) => {
|
app.get("/api/protocols", requireAuth, (req, res) => {
|
||||||
const rt = runtimeForRequest(req);
|
const rt = runtimeForRequest(req);
|
||||||
const hintSingle =
|
const hintSingle = "";
|
||||||
getProfiles().length < 2
|
|
||||||
? IS_COMMUNITY
|
|
||||||
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
|
|
||||||
: "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
|
|
||||||
: "";
|
|
||||||
res.json({
|
res.json({
|
||||||
currentId: rt.profile.id,
|
currentId: rt.profile.id,
|
||||||
currentLabel: rt.profile.label,
|
currentLabel: rt.profile.label,
|
||||||
@@ -2445,7 +2637,6 @@ app.get("/api/protocols", requireAuth, (req, res) => {
|
|||||||
})),
|
})),
|
||||||
singleProfile: getProfiles().length < 2,
|
singleProfile: getProfiles().length < 2,
|
||||||
profilesPersistHint: hintSingle,
|
profilesPersistHint: hintSingle,
|
||||||
edition: editionPayload(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2555,8 +2746,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
clients: rows,
|
clients: rows,
|
||||||
wgShow,
|
wgShow,
|
||||||
warp: warpOut,
|
warp: warpOut,
|
||||||
uiHidden: { ...effectiveUiHidden() },
|
uiHidden: { ...UI_HIDDEN },
|
||||||
edition: editionPayload(),
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -2564,7 +2754,7 @@ app.get("/api/clients", requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/sync-peers", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/sync-peers", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
await rt.backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
@@ -2658,14 +2848,6 @@ app.get("/api/clients/source-report", requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function serveClientConfigExport(req, res) {
|
async function serveClientConfigExport(req, res) {
|
||||||
if (IS_COMMUNITY) {
|
|
||||||
res.status(403).json({
|
|
||||||
error: "Экспорт .conf доступен в версии PRO.",
|
|
||||||
upgradeRequired: true,
|
|
||||||
upgradeUrl: COMMUNITY_UPGRADE_URL,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tokenOk =
|
const tokenOk =
|
||||||
req.method === "GET" &&
|
req.method === "GET" &&
|
||||||
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
|
||||||
@@ -2745,7 +2927,7 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
|
|||||||
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
|
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
|
||||||
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
|
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
|
||||||
*/
|
*/
|
||||||
app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/create", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
await rt.backupRemoteFiles();
|
await rt.backupRemoteFiles();
|
||||||
@@ -2757,7 +2939,7 @@ app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) =>
|
|||||||
}
|
}
|
||||||
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
|
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
|
||||||
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
|
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
|
||||||
const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim();
|
const envHost = configuredEndpointHost();
|
||||||
const hdrHost =
|
const hdrHost =
|
||||||
typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : "";
|
typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : "";
|
||||||
const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : "");
|
const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : "");
|
||||||
@@ -2838,7 +3020,7 @@ AllowedIPs = ${tunnelIp}/32
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/create-cascade", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.cascade) {
|
if (UI_HIDDEN.cascade) {
|
||||||
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
||||||
}
|
}
|
||||||
@@ -2954,7 +3136,7 @@ AllowedIPs = ${tunnelIp}/32
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/import-config", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/import-config", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
try {
|
try {
|
||||||
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
|
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
|
||||||
@@ -2980,7 +3162,7 @@ app.post("/api/clients/import-config", requireAuth, requireProTier, async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/start", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -2992,8 +3174,8 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`);
|
await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
|
||||||
await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`);
|
await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}'`);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -3001,7 +3183,32 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/cascade/upstream", requireAuth, async (req, res) => {
|
||||||
|
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
|
||||||
|
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
|
||||||
|
}
|
||||||
|
const rt = runtimeForRequest(req);
|
||||||
|
try {
|
||||||
|
const conf = buildUpstreamConf(String(req.body?.configText || ""));
|
||||||
|
await rt.backupRemoteFiles();
|
||||||
|
await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`);
|
||||||
|
await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
|
||||||
|
await rt.dockerWriteFile(rt.profile.warpConf, conf);
|
||||||
|
await rt.dockerExec(`chmod 600 '${rt.profile.warpConf}'`);
|
||||||
|
const up = await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}' 2>&1`);
|
||||||
|
const selected = await warpLoadSelectedIps(rt);
|
||||||
|
await warpApplyRouting(rt, selected);
|
||||||
|
await warpPatchStartSh(rt, selected);
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
const show = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
|
||||||
|
res.json({ ok: true, handshake: /latest handshake/.test(show), show: show.slice(0, 2000), output: up.slice(-1500) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(400).json({ error: String(e.message || e).slice(0, 1500) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/warp/stop", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -3010,7 +3217,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
|
|||||||
return res.status(400).json({ error: "WARP не установлен." });
|
return res.status(400).json({ error: "WARP не установлен." });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`);
|
await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -3018,7 +3225,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/warp/routing", requireAuth, async (req, res) => {
|
||||||
if (UI_HIDDEN.warp) {
|
if (UI_HIDDEN.warp) {
|
||||||
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
return res.status(403).json({ error: MSG_UI_WARP_OFF });
|
||||||
}
|
}
|
||||||
@@ -3057,7 +3264,7 @@ app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/disable", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3080,7 +3287,7 @@ app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/enable", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3130,7 +3337,7 @@ AllowedIPs = ${ips}`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3174,7 +3381,7 @@ app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/rename", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
const rawName = req.body?.name ?? req.body?.clientName;
|
const rawName = req.body?.name ?? req.body?.clientName;
|
||||||
@@ -3206,7 +3413,7 @@ app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/clients/delete", requireAuth, async (req, res) => {
|
||||||
const rt = runtimeFromExportRequest(req);
|
const rt = runtimeFromExportRequest(req);
|
||||||
const clientId = req.body?.clientId;
|
const clientId = req.body?.clientId;
|
||||||
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
if (!clientId) return res.status(400).json({ error: "clientId required" });
|
||||||
@@ -3261,6 +3468,8 @@ function runInstanceScript(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectVariantFromHead(head, iface) {
|
function detectVariantFromHead(head, iface) {
|
||||||
|
if (/^RandomTrailers\s*=/m.test(head)) return "awg31";
|
||||||
|
if (/^HeaderProtectionKey\s*=/m.test(head)) return "awg3";
|
||||||
if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy";
|
if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy";
|
||||||
if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2";
|
if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2";
|
||||||
return "awg";
|
return "awg";
|
||||||
@@ -3269,6 +3478,8 @@ function detectVariantFromHead(head, iface) {
|
|||||||
function inferVariantFromProfile(prof) {
|
function inferVariantFromProfile(prof) {
|
||||||
const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase();
|
const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase();
|
||||||
if (raw.includes("legacy") || prof.iface === "wg0") return "legacy";
|
if (raw.includes("legacy") || prof.iface === "wg0") return "legacy";
|
||||||
|
if (raw.includes("awg31") || raw.includes("3.1")) return "awg31";
|
||||||
|
if (raw.includes("awg3") || raw.includes("3.0")) return "awg3";
|
||||||
if (raw.includes("awg2") || raw.includes("2.0")) return "awg2";
|
if (raw.includes("awg2") || raw.includes("2.0")) return "awg2";
|
||||||
return "awg";
|
return "awg";
|
||||||
}
|
}
|
||||||
@@ -3318,7 +3529,7 @@ app.get("/api/instances", requireAuth, async (_req, res) => {
|
|||||||
res.json({ instances: items, variants: INSTANCE_VARIANTS });
|
res.json({ instances: items, variants: INSTANCE_VARIANTS });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/create", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/create", requireAuth, async (req, res) => {
|
||||||
const variant = String(req.body?.variant || "").trim();
|
const variant = String(req.body?.variant || "").trim();
|
||||||
const port = Number(req.body?.port);
|
const port = Number(req.body?.port);
|
||||||
if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." });
|
if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." });
|
||||||
@@ -3348,7 +3559,7 @@ app.post("/api/instances/create", requireAuth, requireProTier, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/delete", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/delete", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const list = loadManagedProfiles();
|
const list = loadManagedProfiles();
|
||||||
const found = list.find((p) => p.id === id);
|
const found = list.find((p) => p.id === id);
|
||||||
@@ -3369,7 +3580,7 @@ async function resolveProfileContainer(id) {
|
|||||||
catch { return prof.container || null; }
|
catch { return prof.container || null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/stop", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const container = await resolveProfileContainer(id);
|
const container = await resolveProfileContainer(id);
|
||||||
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
||||||
@@ -3377,7 +3588,7 @@ app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) =>
|
|||||||
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/instances/start", requireAuth, requireProTier, async (req, res) => {
|
app.post("/api/instances/start", requireAuth, async (req, res) => {
|
||||||
const id = String(req.body?.id || "").trim();
|
const id = String(req.body?.id || "").trim();
|
||||||
const container = await resolveProfileContainer(id);
|
const container = await resolveProfileContainer(id);
|
||||||
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
if (!container) return res.status(404).json({ error: "Инстанс не найден." });
|
||||||
@@ -3404,13 +3615,9 @@ app.listen(PORT, "0.0.0.0", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
}
|
|
||||||
}, SCHEDULER_MS);
|
}, SCHEDULER_MS);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!IS_COMMUNITY) {
|
|
||||||
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
|
||||||
}
|
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|||||||
Reference in New Issue
Block a user