13 Commits
v1.3.0 ... main

Author SHA1 Message Date
Бобырев Андрей
357327b737 README: screenshot of protocol install
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:44:40 +03:00
Бобырев Андрей
e38a61197d docs: 09.4 two sources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:43:46 +03:00
Бобырев Андрей
e64e269efb install: two sources (git.bobyrev.com, GitHub), first that answers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:42:34 +03:00
Бобырев Андрей
246bb4aeb7 docs: 09.4 move to Gitea, news 1.4.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:40:49 +03:00
Бобырев Андрей
b64d1aba5b v1.4.2: move to git.bobyrev.com (GitHub account suspended), README photo + install command
Some checks failed
Publish Pro image to GHCR / build-push (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 04:39:35 +03:00
Бобырев Андрей
cb230fe50e docs: split old Notes into 09.3.N, fix 00 (news bot), move check scripts to scripts/checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:44:20 +03:00
Бобырев Андрей
dbab96f7fd docs: 09.2 — install.sh checked on clean Ubuntu 24.04 LXC
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:36:48 +03:00
Бобырев Андрей
d34f25d544 docs: 09.2 v1.4.1, news, gitea
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:25:57 +03:00
Бобырев Андрей
1be56c1f60 v1.4.1: installer installs Docker, step-by-step quick start, drop confusing AWG_PROFILES hint
Some checks failed
Publish Pro image to GHCR / build-push (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:20:11 +03:00
Бобырев Андрей
a843f300a7 docs: 00, 09.2 v1.4.0, archive closed panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:09:32 +03:00
Бобырев Андрей
74849a8e2c v1.4.0: Xray VLESS-Reality, fully open panel (no community edition)
Some checks failed
Publish Pro image to GHCR / build-push (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:08:09 +03:00
Бобырев Андрей
693f9513f1 docs: 09.1 news posted 2026-10-23 00:55:23 +03:00
Бобырев Андрей
2faa91de9b docs: 09.1 v1.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:43:09 +03:00
36 changed files with 1129 additions and 354 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ npm-debug.log*
*.swp *.swp
.env .env
.env.* .env.*
Новости/

View 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. Код живёт на Gitea `git.bobyrev.com/PCA/amnezia_web-PRO` (GitHub-аккаунт заблокирован 10.2026, см. 09.4). Релиз: тег + push в remote `gitea` (через ssh 127.0.0.1) + выпуск через API Gitea, затем Notes, новость в группу PCA, тема «Новости» (chat_id -1003902709196, message_thread_id=131) ботом @PCAdmin27_bot (токен `TELEGRAM_MOD_BOT_TOKEN` в «Закрытые продукты/home_Friends_It/ДОСТУПЫ.md»); PCA_jarvis_bot не использовать.
## Оглавление
- `PROJECT_INDEX.md` — что где лежит, установка
- `09. История и грабли` — оглавление записей 09.N
- `scripts/checks/` — проверки парка AWG (запускать внутри контейнера панели, читают /app/data/data.json)
- `Новости/` — тексты новостей релизов (не в git)

View File

@@ -0,0 +1,7 @@
# 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 — проект переехал в папку-группу; код не менялся.
- [09.4 Версия 1.4.2 — GitHub заблокирован, переезд на Gitea](<09.4 Версия 1.4.2 — GitHub заблокирован, переезд на Gitea.md>)

View File

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

View 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 нет (ссылка копируется) — добавить, если попросят.

View 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>)

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

View 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

View 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

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

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

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

View 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

View 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

View 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-дубли создаваться не будут.

View 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

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

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

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

View File

@@ -0,0 +1,22 @@
# 09.4 Версия 1.4.2 — GitHub заблокирован, переезд на Gitea (23.10.2026)
**СИМПТОМ:** «GitHub не отвечает». `git push origin` → `403 Your account is suspended`; github.com/andrey271192/amnezia_web-PRO и raw.githubusercontent.com → 404 для всех. Старая команда установки сломана у всех пользователей.
**ПРИЧИНА:** аккаунт andrey271192 на GitHub заблокирован (suspended). Почему — не выяснено, писать в support.github.com.
**КАК ПРОВЕРИТЬ ЗА МИНУТУ:**
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://github.com/andrey271192/amnezia_web-PRO # 404 = всё ещё заблокирован
curl -s -o /dev/null -w "%{http_code}\n" https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh # 200
```
**ИСПРАВЛЕНИЕ:**
- Gitea: старое pull-зеркало переименовано в `PCA/amnezia_web-PRO-github-mirror` (не удалено), создан обычный репозиторий `PCA/amnezia_web-PRO`, залиты main и все теги, выпуск v1.4.2. Remote `gitea` — основной, push через `ssh -i ~/.config/svault/id_svault andrey@127.0.0.1` (из сессии .153 напрямую недоступен).
- install.sh: код качается с `https://git.bobyrev.com/PCA/amnezia_web-PRO/archive/main.tar.gz` (переменные `GIT_BASE`, `GITHUB_REPO=PCA/amnezia_web-PRO`), подсказка удаления — тоже с Gitea.
- README/PROJECT_INDEX/панель: все ссылки на git.bobyrev.com; сверху README — фото `docs/screenshots/panel-1.4.png`, что умеет, команда установки.
- Проверено на school-friends: установка с Gitea → 1.4.2, :8080 = 200; удаление по новой ссылке — ок.
- Новость в PCA, тема 131, сообщение 902 (фото + описание + установка), бот @PCAdmin27_bot. Текст — `Новости/Новость 1.4.2…`.
**ПРОФИЛАКТИКА:** основной дом кода — Gitea, GitHub только если аккаунт вернут (тогда снова зеркалить туда). Не осталось: ссылка на kaskad_web_vpn и domen_hydra в README/панели ведут на GitHub — их проекты тоже надо переносить. Старая новость 881 (1.4.0) осталась со старой ссылкой.
**Дополнение (23.10.2026, по просьбе Андрея):** GitHub и Gitea работают параллельно. install.sh пробует по очереди `git.bobyrev.com/PCA/amnezia_web-PRO` → `github.com/andrey271192/amnezia_web-PRO` (скачивает в файл, потом распаковывает — пустой ответ не считается успехом), README показывает обе команды. На Gitea заведены выпуски v1.3.0, v1.4.0, v1.4.1, v1.4.2. Проверено на school-friends. В группу — сообщение 903 со ссылкой на выпуски. Когда GitHub вернут: `git push origin main --tags` и выпуски там. Один раз установка не скачала с Gitea (разовый сбой), повтор — ок; если повторится — смотреть доступность git.bobyrev.com снаружи.

View File

@@ -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 — допушить (была недоступна).

112
README.md
View File

@@ -1,10 +1,35 @@
# 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. <img src="docs/screenshots/panel-protocols.png" alt="Установка AmneziaWG 3.1/3.0/2.0/1.x и WireGuard прямо из панели с выбором порта" width="780"/>
**Что умеет:** клиенты 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
# запасной — GitHub (если основной недоступен)
curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web-PRO/main/scripts/install.sh | sudo bash
```
Установщик сам качает код с того источника, который отвечает: сначала git.bobyrev.com, потом GitHub.
Панель: `http://IP:8080`, вход — пароль `admin` (смените сразу). Код и выпуски: [git.bobyrev.com/PCA/amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO) · [выпуски](https://git.bobyrev.com/PCA/amnezia_web-PRO/releases) · копия на [GitHub](https://github.com/andrey271192/amnezia_web-PRO).
## Что нового в 1.4.2
- **Проект переехал на [git.bobyrev.com/PCA/amnezia_web-PRO](https://git.bobyrev.com/PCA/amnezia_web-PRO)** — GitHub-аккаунт заблокирован. Установщик берёт код с того из двух источников (git.bobyrev.com или GitHub), что отвечает.
## Что нового в 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 ## Что нового в 1.3.0
@@ -24,6 +49,55 @@ Base project **[amnezia_web](https://github.com/andrey271192/amnezia_web)** rema
- **Светлая / тёмная тема** — кнопка «◐ Тема» в шапке (по умолчанию как в системе). - **Светлая / тёмная тема** — кнопка «◐ Тема» в шапке (по умолчанию как в системе).
- Старые установки не затрагиваются: контейнеры, поставленные приложением Amnezia, остаются как были. - Старые установки не затрагиваются: контейнеры, поставленные приложением 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`.
## Что открывается по какому порту ## Что открывается по какому порту
| Адрес | Назначение | | Адрес | Назначение |
@@ -50,7 +124,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`**. Смените пароль сразу после входа.
@@ -60,13 +134,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`
@@ -78,7 +152,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`** — установщик подставит профили из файла (см. абзац про снимок ниже таблицы).
@@ -106,7 +180,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` и сессией |
@@ -154,14 +228,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` нестандартный.
@@ -248,7 +322,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`) дублируют соответствующий токен.
@@ -261,7 +335,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.
@@ -287,13 +361,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`), пересоберите без повторной загрузки архива:
@@ -319,13 +393,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
``` ```
--- ---
@@ -344,7 +418,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`.
@@ -376,7 +450,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) |
@@ -399,7 +473,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)

View File

@@ -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-контейнеров.

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "amnezia-admin", "name": "amnezia-admin",
"version": "1.3.0", "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",

View File

@@ -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) {
@@ -630,6 +558,7 @@ loginForm.addEventListener("submit", async (ev) => {
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings(); await loadSettings();
await loadXray();
} catch (e) { } catch (e) {
loginError.textContent = String(e.message || e); loginError.textContent = String(e.message || e);
} }
@@ -649,6 +578,7 @@ refreshBtn.addEventListener("click", () => {
loadClients(); loadClients();
loadInstances(); loadInstances();
loadSettings(); loadSettings();
loadXray();
}); });
const cascadeForm = document.querySelector("#cascade-form"); const cascadeForm = document.querySelector("#cascade-form");
@@ -838,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}. Пароль не сохраняется.`
@@ -938,38 +860,27 @@ 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( btn("Переименовать", "btn small ghost", () => void renameClient(c))
btn("Переименовать", "btn small ghost", () => void renameClient(c)) );
); nameWrap.append(strong, renameWrap);
nameWrap.append(strong, renameWrap); if (!c.exportAvailable) {
if (!c.exportAvailable) { const hintFold = document.createElement("details");
const hintFold = document.createElement("details"); hintFold.className = "hint-mini";
hintFold.className = "hint-mini"; const sum = document.createElement("summary");
const sum = document.createElement("summary"); sum.textContent = c.missingClientTable
sum.textContent = c.missingClientTable ? "Peer найден в конфиге, но нет записи clientsTable"
? "Peer найден в конфиге, но нет записи clientsTable" : "Нет готового .conf на сервере (last_config)";
: "Нет готового .conf на сервере (last_config)"; hintFold.appendChild(sum);
hintFold.appendChild(sum); const exHint = document.createElement("p");
const exHint = document.createElement("p"); exHint.className = "muted export-missing-hint";
exHint.className = "muted export-missing-hint"; exHint.textContent = c.missingClientTable
exHint.textContent = c.missingClientTable ? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт."
? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт." : "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia.";
: "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; 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);
@@ -991,51 +902,42 @@ 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) { if (c.activeInConf) {
const lock = document.createElement("span"); actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
lock.className = "muted";
lock.textContent = "Только PRO";
actTd.appendChild(lock);
} else { } else {
if (c.activeInConf) { actTd.appendChild(
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c))); iconBtn("▶", "Включить", "btn small primary icon", () => mutate("/api/clients/enable", c.clientId))
} else { );
actTd.appendChild(
iconBtn("▶", "Включить", "btn small primary icon", () => mutate("/api/clients/enable", c.clientId))
);
}
if (c.exportAvailable) {
const direct = document.createElement("a");
direct.className = "btn small ghost";
direct.href = clientExportGetUrl(c.clientId);
direct.textContent = "🔗"; direct.classList.add("icon");
direct.rel = "noopener";
direct.title =
"Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c)));
actTd.appendChild(direct);
actTd.appendChild(
iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => {
const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
}),
);
}
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
} }
if (c.exportAvailable) {
const direct = document.createElement("a");
direct.className = "btn small ghost";
direct.href = clientExportGetUrl(c.clientId);
direct.textContent = "🔗"; direct.classList.add("icon");
direct.rel = "noopener";
direct.title =
"Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c)));
actTd.appendChild(direct);
actTd.appendChild(
iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => {
const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
}),
);
}
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);
@@ -1651,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) {
@@ -1661,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 || "";
@@ -1733,6 +1631,7 @@ async function boot() {
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings(); await loadSettings();
await loadXray();
} else { } else {
showLogin(); showLogin();
loginPassword.focus(); loginPassword.focus();
@@ -1788,3 +1687,100 @@ document.querySelector("#upstream-form")?.addEventListener("submit", async (ev)
if (b) b.disabled = false; 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);
}
});

View File

@@ -8,14 +8,14 @@
<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">
<script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script> <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.3.0"> <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>
@@ -39,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>
@@ -78,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>
@@ -190,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>
@@ -376,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>
@@ -423,6 +460,6 @@
</div> </div>
</dialog> </dialog>
<script src="/app.js?v=1.3.0" type="module"></script> <script src="/app.js?v=1.4.2" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1097,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;

53
scripts/checks/audit2.py Normal file
View 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расхождений:", плохо)

View 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проблемных строк:", беда)

View 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]))

View 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("серверов с остатками:", всего)

View 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"], итог["сбой"], итог["мусор"]))

View File

@@ -3,6 +3,8 @@
set -euo pipefail set -euo pipefail
GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web-PRO}" GITHUB_REPO="${GITHUB_REPO:-andrey271192/amnezia_web-PRO}"
GITEA_REPO="${GITEA_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 +23,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,28 +68,40 @@ 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:-1}"
--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:-}" # Два источника одного кода: Gitea (git.bobyrev.com) и GitHub — берём первый, что ответил.
if [[ -z "${CURL_URL}" ]]; then if [[ -n "${GITHUB_REPO_URL_OVERRIDE:-}" ]]; then
CURL_URL="https://github.com/${GITHUB_REPO}/archive/refs/heads/${BRANCH}.tar.gz" SOURCES=("${GITHUB_REPO_URL_OVERRIDE}")
else
SOURCES=("${GIT_BASE}/${GITEA_REPO}/archive/${BRANCH}.tar.gz"
"https://github.com/${GITHUB_REPO}/archive/refs/heads/${BRANCH}.tar.gz")
fi fi
if ! curl "${CURL_OPTS[@]}" "${CURL_URL}" | tar xz -C "${TMP}"; then __ok=""
echo "Ошибка: не удалось скачать или распаковать архив (${CURL_URL})." for CURL_URL in "${SOURCES[@]}"; do
echo "Подсказка: проверьте доступ к github.com, branch/repo, при необходимости export GITHUB_REPO_URL_OVERRIDE='…' или INSTALL_SCRIPT_VERBOSE=1." rm -rf "${TMP:?}"/*
if curl "${CURL_OPTS[@]}" -o "${TMP}/src.tgz" "${CURL_URL}" 2>/dev/null && tar xzf "${TMP}/src.tgz" -C "${TMP}" 2>/dev/null; then
rm -f "${TMP}/src.tgz"; __ok=1; break
fi
echo "→ ${CURL_URL} недоступен, пробую следующий источник…"
done
if [[ -z "${__ok}" ]]; then
echo "Ошибка: не удалось скачать код ни с одного источника: ${SOURCES[*]}"
echo "Подсказка: проверьте доступ к git.bobyrev.com и github.com, при необходимости export GITHUB_REPO_URL_OVERRIDE='…'."
exit 1 exit 1
fi fi
echo "→ Код взят с ${CURL_URL}"
echo "→ Перенос распакованного дерева в ${INSTALL_DIR}…" echo "→ Перенос распакованного дерева в ${INSTALL_DIR}…"
# API-tarball распаковывается в каталог вида `<owner>-<repo>-<sha7>`, # API-tarball распаковывается в каталог вида `<owner>-<repo>-<sha7>`,
# archive/refs/heads — в `<repo>-<branch>`. Поэтому берём первый каталог TMP. # archive/refs/heads — в `<repo>-<branch>`. Поэтому берём первый каталог TMP.
@@ -283,20 +298,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)"
@@ -368,4 +369,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}/${GITEA_REPO}/raw/branch/${BRANCH}/scripts/uninstall.sh | sudo bash"

336
server.js
View File

@@ -51,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 = () => {
@@ -457,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 =
@@ -2106,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) => {
@@ -2154,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",
@@ -2180,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));
@@ -2196,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)) {
@@ -2306,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);
@@ -2320,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();
@@ -2335,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:
@@ -2367,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 });
} }
@@ -2411,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) {
@@ -2462,7 +2599,7 @@ app.get("/api/settings", requireAuth, (_req, res) => {
}); });
}); });
app.post("/api/settings", requireAuth, requireProTier, async (req, res) => { app.post("/api/settings", requireAuth, async (req, res) => {
const raw = String(req.body?.endpointHost ?? "").trim(); const raw = String(req.body?.endpointHost ?? "").trim();
let host = ""; let host = "";
if (raw) { if (raw) {
@@ -2489,12 +2626,7 @@ app.post("/api/settings", requireAuth, requireProTier, async (req, res) => {
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,
@@ -2505,7 +2637,6 @@ app.get("/api/protocols", requireAuth, (req, res) => {
})), })),
singleProfile: getProfiles().length < 2, singleProfile: getProfiles().length < 2,
profilesPersistHint: hintSingle, profilesPersistHint: hintSingle,
edition: editionPayload(),
}); });
}); });
@@ -2615,16 +2746,15 @@ 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);
res.status(500).json({ error: String(e.message || e) }); res.status(500).json({ error: String(e.message || e) });
} }
}); });
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();
@@ -2718,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 : "");
@@ -2805,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();
@@ -2898,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 });
} }
@@ -3014,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 ?? "";
@@ -3040,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 });
} }
@@ -3061,7 +3183,7 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
} }
}); });
app.post("/api/cascade/upstream", requireAuth, requireProTier, async (req, res) => { app.post("/api/cascade/upstream", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) { if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
return res.status(403).json({ error: MSG_UI_CASCADE_OFF }); return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
} }
@@ -3086,7 +3208,7 @@ app.post("/api/cascade/upstream", requireAuth, requireProTier, async (req, res)
} }
}); });
app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => { 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 });
} }
@@ -3103,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 });
} }
@@ -3142,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" });
@@ -3165,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" });
@@ -3215,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" });
@@ -3259,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;
@@ -3291,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" });
@@ -3407,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: "Неизвестный вариант протокола." });
@@ -3437,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);
@@ -3458,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: "Инстанс не найден." });
@@ -3466,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: "Инстанс не найден." });
@@ -3493,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);