14 Commits

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
Бобырев Андрей
75345ebc03 v1.3.0: AWG 3.1/3.0/2.0/1.x/WireGuard from the panel, real cascade, server domain, light/dark theme
Some checks failed
Publish Pro image to GHCR / build-push (push) Has been cancelled
- client .conf: only obfuscation keys present on the server (AWG 1.x got S3/S4/I1 -> no traffic)
- install.sh no longer puts panel-managed containers into AWG_PROFILES (wrong instance on switch)
- UDP port busy check via docker instead of ss inside the panel container
- default profile never hijacks a managed container

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:42:16 +03:00
37 changed files with 1488 additions and 475 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 — допушить (была недоступна).

130
README.md
View File

@@ -1,10 +1,102 @@
# 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
- **Установка протоколов прямо с сайта** (раздел «Протоколы / инстансы»), несколько версий рядом на разных портах:
| Вариант | Для кого |
|---|---|
| AmneziaWG 3.1 | новейший: защита заголовков, случайные хвосты; свежее приложение Amnezia |
| AmneziaWG 3.0 | HeaderProtectionKey, паддинг |
| AmneziaWG 2.0 | рекомендуемый по умолчанию (S3/S4) |
| AmneziaWG 1.x | роутеры Keenetic/OpenWrt и старые приложения (без S3/S4) |
| WireGuard (Legacy) | любой WG-клиент, без обфускации |
- **Исправлено:** конфиг клиента для AWG 1.x получал лишние S3/S4/I1 — рукопожатие было, интернета нет. Теперь в .conf попадают только параметры, которые есть на сервере.
- **Исправлено:** при обновлении установщик записывал контейнеры панели в `AWG_PROFILES`, и переключение инстанса создавало клиента не там.
- **Исправлено:** проверка занятого UDP-порта смотрела внутрь контейнера панели и ничего не находила.
- **Домен сервера** — поле «Домен или IP сервера в конфигах клиентов»: подставляется в Endpoint всех выгрузок, домен проверяется на резолв.
- **Настоящий каскад** — в разделе «Cloudflare WARP / каскад» вставьте клиентский .conf другого сервера: отмеченные клиенты выходят в интернет через него (переживает перезапуск контейнера).
- **Светлая / тёмная тема** — кнопка «◐ Тема» в шапке (по умолчанию как в системе).
- Старые установки не затрагиваются: контейнеры, поставленные приложением Amnezia, остаются как были.
## Быстрый старт: от пустого сервера до VPN за 10 минут
**Что нужно:** VPS с Ubuntu 22.04/24.04 или Debian 12 (1 ГБ ОЗУ хватает), доступ root по SSH. Docker ставить не нужно — установщик поставит его сам.
**1. Установите панель.** Зайдите на сервер по SSH и выполните:
```bash
curl -fsSL https://git.bobyrev.com/PCA/amnezia_web-PRO/raw/branch/main/scripts/install.sh | sudo bash
```
В конце будет строка `Админ-панель: http://IP:8080`.
**2. Откройте порты** в фаерволе хостинга (панель управления VPS) и, если включён, в `ufw`:
| Порт | Зачем |
|---|---|
| `8080/tcp` | админ-панель |
| `80/tcp` | страница для пользователей (необязательно, `SKIP_LANDING=1` — без неё) |
| UDP-порт AmneziaWG, который выберете на шаге 4 (например `41010/udp`) | VPN AmneziaWG |
| TCP-порт Xray, если будете ставить (например `443/tcp`) | VPN Xray |
```bash
sudo ufw allow 8080/tcp && sudo ufw allow 41010/udp # пример, если ufw включён
```
**3. Войдите** на `http://IP:8080` — пароль **`admin`**. Сразу смените его в блоке «Сменить пароль».
**4. Разверните протокол.** Раздел «Протоколы / инстансы» → выберите вариант → порт (например `41010`) → «Развернуть инстанс». Какой выбрать:
- **AmneziaWG 2.0** — по умолчанию, для телефонов и ПК (приложение Amnezia VPN);
- **AmneziaWG 3.1 / 3.0** — самые новые, нужна свежая версия приложения;
- **AmneziaWG 1.x** — для роутеров Keenetic / OpenWrt;
- **WireGuard** — для любых WG-клиентов, без маскировки.
Если на сервере уже стоит VPN, поставленный приложением Amnezia, — шаг пропустите: панель найдёт его сама.
**5. Создайте клиента.** Блок «Новый клиент» → имя → «Создать и скачать .conf». Файл импортируйте в Amnezia VPN (на телефоне/ПК: «+» → «Файл с настройками»).
**6. (по желанию) Xray VLESS‑Reality** — если AmneziaWG у провайдера режут. Раздел «Xray VLESS‑Reality» → порт TCP (`443`, если свободен) и сайт‑маскировка → «Установить Xray» (~1 минута) → «Создать» клиента — ссылка `vless://` скопируется. Вставьте её в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ («+» → «Из буфера обмена»).
**7. Свой домен (по желанию).** В «Протоколы / инстансы» → «Домен или IP сервера в конфигах клиентов» → «Сохранить адрес». Без него в конфиги подставляется IP.
**Обновление** — та же команда из шага 1: клиенты, пароль и настройки сохраняются. **Удаление** — см. «Удаление одной командой».
**Если не работает:**
- «Рукопожатие есть, интернета нет» или нет рукопожатия — не открыт UDP-порт у хостинга (шаг 2).
- Панель не открывается — не открыт `8080/tcp`; проверка на сервере: `curl -s localhost:8080/health`.
- Xray: «TCP-порт занят» — выберите другой порт (например `8443`) и откройте его.
- Логи панели: `sudo docker logs --tail 50 amnezia-admin`.
## Что открывается по какому порту ## Что открывается по какому порту
@@ -32,7 +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`**. Смените пароль сразу после входа.
@@ -42,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`
@@ -60,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`** — установщик подставит профили из файла (см. абзац про снимок ниже таблицы).
@@ -88,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` и сессией |
@@ -136,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` нестандартный.
@@ -230,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`) дублируют соответствующий токен.
@@ -243,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.
@@ -269,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`), пересоберите без повторной загрузки архива:
@@ -301,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
``` ```
--- ---
@@ -326,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`.
@@ -358,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) |
@@ -381,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.2.39", "version": "1.4.2",
"private": false, "private": false,
"description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)", "description": "amnezia_web-PRO — веб-панель AmneziaWG: несколько инстансов, WARP, каскад/Endpoint, экспорт .conf (Docker)",
"license": "MIT", "license": "MIT",

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) {
@@ -629,6 +557,8 @@ loginForm.addEventListener("submit", async (ev) => {
await loadTimeSyncCaps(); await loadTimeSyncCaps();
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings();
await loadXray();
} catch (e) { } catch (e) {
loginError.textContent = String(e.message || e); loginError.textContent = String(e.message || e);
} }
@@ -647,6 +577,8 @@ logoutBtn.addEventListener("click", async () => {
refreshBtn.addEventListener("click", () => { refreshBtn.addEventListener("click", () => {
loadClients(); loadClients();
loadInstances(); loadInstances();
loadSettings();
loadXray();
}); });
const cascadeForm = document.querySelector("#cascade-form"); const cascadeForm = document.querySelector("#cascade-form");
@@ -836,14 +768,6 @@ async function loadTimeSyncCaps() {
const btn = document.querySelector("#sync-host-time"); const btn = document.querySelector("#sync-host-time");
try { try {
const c = await api("/api/time-sync-capabilities"); const c = await api("/api/time-sync-capabilities");
if (editionState.readOnlyClients || c.communityBlocked) {
if (hint) {
hint.textContent =
"В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO.";
}
if (btn) btn.disabled = true;
return;
}
if (hint) { if (hint) {
hint.textContent = c.hostTimeSync hint.textContent = c.hostTimeSync
? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.`
@@ -936,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);
@@ -989,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);
@@ -1649,7 +1553,6 @@ async function loadClients() {
try { try {
setStatus("Загрузка…", false); setStatus("Загрузка…", false);
const data = await api(`/api/clients${currentProfileQueryString()}`); const data = await api(`/api/clients${currentProfileQueryString()}`);
applyEditionPayload(data);
applyUiHiddenFromPayload(data); applyUiHiddenFromPayload(data);
const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
if (uiHidden.users) { if (uiHidden.users) {
@@ -1659,10 +1562,7 @@ async function loadClients() {
} else { } else {
if (syncPeersBtn) { if (syncPeersBtn) {
syncPeersBtn.hidden = false; syncPeersBtn.hidden = false;
syncPeersBtn.disabled = editionState.readOnlyClients; syncPeersBtn.title = "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
syncPeersBtn.title = editionState.readOnlyClients
? "Доступно в PRO"
: "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
} }
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
wgShowEl.textContent = data.wgShow || ""; wgShowEl.textContent = data.wgShow || "";
@@ -1730,6 +1630,8 @@ async function boot() {
await loadTimeSyncCaps(); await loadTimeSyncCaps();
await loadClients(); await loadClients();
await loadInstances(); await loadInstances();
await loadSettings();
await loadXray();
} else { } else {
showLogin(); showLogin();
loginPassword.focus(); loginPassword.focus();
@@ -1738,3 +1640,147 @@ async function boot() {
initMtprotoLinkCopy(); initMtprotoLinkCopy();
boot(); boot();
// ── Тема, адрес сервера, вышестоящий сервер каскада ──
document.querySelector("#theme-toggle")?.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
try { localStorage.setItem("theme", next); } catch { /* private mode */ }
});
async function loadSettings() {
const input = document.querySelector("#settings-endpoint");
const hint = document.querySelector("#settings-hint");
if (!input) return;
try {
const st = await api("/api/settings");
input.value = st.endpointHost || "";
if (hint) hint.textContent = `Сейчас в конфигах клиентов: ${st.effectiveEndpoint || "адрес из браузера"}. Новый адрес действует на все выгрузки .conf, в том числе старых клиентов.`;
} catch { /* старый сервер без /api/settings */ }
}
document.querySelector("#settings-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const endpointHost = document.querySelector("#settings-endpoint")?.value.trim() || "";
try {
const r = await api("/api/settings", { method: "POST", body: JSON.stringify({ endpointHost }) });
setStatus(r.resolved ? `Адрес сохранён: ${endpointHost} → ${r.resolved.join(", ")}` : "Адрес сохранён.", false);
await loadSettings();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
document.querySelector("#upstream-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const configText = document.querySelector("#upstream-conf")?.value || "";
const b = ev.target.querySelector("button[type=submit]");
if (b) b.disabled = true;
try {
setStatus("Подключаю вышестоящий сервер…", false);
const r = await api("/api/cascade/upstream", { method: "POST", body: JSON.stringify({ configText }) });
setStatus(r.handshake ? "Каскад подключён: рукопожатие есть. Отметьте клиентов ниже и нажмите «Применить»." : "Туннель поднят, но рукопожатия пока нет — проверьте Endpoint и UDP-порт вышестоящего сервера.", !r.handshake);
await loadClients();
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
if (b) b.disabled = false;
}
});
async function loadXray() {
const line = document.querySelector("#xray-status-line");
try {
const x = await api("/api/xray/status");
document.querySelector("#xray-install-form").style.display = x.installed ? "none" : "";
document.querySelector("#xray-manage").style.display = x.installed ? "" : "none";
if (line) {
line.textContent = !x.installed
? "не установлен"
: x.running
? `порт ${x.port} · ${x.sni} · клиентов: ${x.clients.length}`
: "контейнер остановлен";
}
if (x.error) setStatus(x.error, true);
const rows = document.querySelector("#xray-rows");
rows.innerHTML = "";
for (const c of x.clients) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
nameTd.innerHTML = `<strong>${escapeHtml(c.name)}</strong>`;
const stTd = document.createElement("td");
stTd.innerHTML = `<span class="badge ${c.enabled ? "on" : "off"}">${c.enabled ? "Включён" : "Выключен"}</span>`;
const actTd = document.createElement("td");
actTd.className = "actions";
const act = (action) => async () => {
if (action === "delete" && !confirm(`Удалить клиента ${c.name}?`)) return;
try {
await api("/api/xray/clients/update", { method: "POST", body: JSON.stringify({ id: c.id, action }) });
await loadXray();
} catch (e) {
setStatus(String(e.message || e), true);
}
};
actTd.append(
iconBtn("⧉", "Копировать ссылку vless://", "btn small ghost icon", async () => {
const ok = await copyTextToClipboard(c.link);
setStatus(ok ? `Ссылка ${c.name} скопирована.` : "Не удалось скопировать.", !ok);
}),
c.enabled
? iconBtn("⏻", "Выключить", "btn small ghost icon", act("disable"))
: iconBtn("▶", "Включить", "btn small primary icon", act("enable")),
iconBtn("🗑", "Удалить", "btn small warn icon", act("delete")),
);
tr.append(nameTd, stTd, actTd);
rows.appendChild(tr);
}
} catch (e) {
if (line) line.textContent = String(e.message || e);
}
}
document.querySelector("#xray-install-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const b = ev.target.querySelector("button[type=submit]");
if (b) b.disabled = true;
try {
setStatus("Ставлю Xray (сборка образа ~1 мин)…", false);
await api("/api/xray/install", {
method: "POST",
body: JSON.stringify({
port: document.querySelector("#xray-port").value,
sni: document.querySelector("#xray-sni").value,
}),
});
setStatus("Xray установлен. Создайте клиента.", false);
await loadXray();
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
if (b) b.disabled = false;
}
});
document.querySelector("#xray-client-form")?.addEventListener("submit", async (ev) => {
ev.preventDefault();
const input = document.querySelector("#xray-client-name");
try {
const r = await api("/api/xray/clients/create", { method: "POST", body: JSON.stringify({ name: input.value }) });
input.value = "";
const ok = await copyTextToClipboard(r.link);
setStatus(ok ? "Клиент создан, ссылка скопирована." : "Клиент создан.", false);
await loadXray();
} catch (e) {
setStatus(String(e.message || e), true);
}
});
document.querySelector("#xray-remove")?.addEventListener("click", async () => {
if (!confirm("Удалить контейнер Xray? Клиенты Xray перестанут работать.")) return;
try {
await api("/api/xray/remove", { method: "POST", body: "{}" });
await loadXray();
} catch (e) {
setStatus(String(e.message || e), true);
}
});

View File

@@ -7,14 +7,15 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css?v=1.2.28"> <script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script>
<link rel="stylesheet" href="/styles.css?v=1.4.2">
</head> </head>
<body class="page"> <body class="page">
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат"> <aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
<div class="panel-promo-strip-inner"> <div class="panel-promo-strip-inner">
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p> <p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
<div class="panel-promo-links"> <div class="panel-promo-links">
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · PRO</a> <a class="panel-promo-a" href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ Git</a>
<span class="panel-promo-sep" aria-hidden="true">·</span> <span class="panel-promo-sep" aria-hidden="true">·</span>
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a> <a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
<span class="panel-promo-sep" aria-hidden="true">·</span> <span class="panel-promo-sep" aria-hidden="true">·</span>
@@ -38,7 +39,7 @@
</form> </form>
<nav class="gate-donate muted" aria-label="Поддержка и донат"> <nav class="gate-donate muted" aria-label="Поддержка и донат">
Поддержка веб‑панелей (вне этого сервера):<br/> Поддержка веб‑панелей (вне этого сервера):<br/>
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub · PRO</a> <a href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub</a>
<span class="gate-donate-dot" aria-hidden="true">·</span> <span class="gate-donate-dot" aria-hidden="true">·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a> <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
<span class="gate-donate-dot" aria-hidden="true">·</span> <span class="gate-donate-dot" aria-hidden="true">·</span>
@@ -58,6 +59,7 @@
</div> </div>
<div class="token-box"> <div class="token-box">
<div class="session-actions"> <div class="session-actions">
<button type="button" id="theme-toggle" class="btn ghost full" title="Светлая / тёмная тема">◐ Тема</button>
<button type="button" id="logout" class="btn ghost full">Выйти</button> <button type="button" id="logout" class="btn ghost full">Выйти</button>
</div> </div>
<details class="pw-change"> <details class="pw-change">
@@ -76,7 +78,6 @@
</div> </div>
</header> </header>
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
<section class="toolbar"> <section class="toolbar">
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div> <div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
@@ -121,7 +122,7 @@
<summary class="fold-summary"> <summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span> <span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles"> <span class="fold-titles">
<span class="fold-h">Cloudflare WARP</span> <span class="fold-h">Cloudflare WARP / каскад</span>
<span class="muted fold-meta" id="warp-status-line"></span> <span class="muted fold-meta" id="warp-status-line"></span>
</span> </span>
</summary> </summary>
@@ -132,6 +133,11 @@
Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>. Один раз на хосте VPS под root: в каталоге с репозиторием выполните <code class="inline">bash scripts/warp-amnezia.sh install</code>.
Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README. Затем отметьте клиентов ниже и нажмите «Применить» (контейнер AWG перезапустится). Установить или удалить WARP с хоста можно кнопками ниже (пароль root по SSH — как при синхронизации времени). Убрать вручную: <code class="inline">bash scripts/warp-amnezia.sh uninstall</code> — см. README.
</p> </p>
<form id="upstream-form" class="cascade-form">
<label for="upstream-conf"><strong>Каскад через другой сервер</strong> — вставьте клиентский .conf (AmneziaWG или WireGuard) другого сервера. Он станет выходом вместо WARP: отмеченные ниже клиенты пойдут в интернет через тот сервер.</label>
<textarea id="upstream-conf" rows="6" placeholder="[Interface]&#10;PrivateKey = …&#10;[Peer]&#10;Endpoint = …" autocomplete="off"></textarea>
<button type="submit" class="btn primary">Подключить вышестоящий сервер</button>
</form>
<div id="warp-actions" class="warp-actions"></div> <div id="warp-actions" class="warp-actions"></div>
<div id="warp-client-list" class="warp-client-list"></div> <div id="warp-client-list" class="warp-client-list"></div>
<details class="raw warp-raw"> <details class="raw warp-raw">
@@ -183,6 +189,44 @@
</div> </div>
</details> </details>
<details class="panel-fold" id="xray-panel">
<summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span>
<span class="fold-titles">
<span class="fold-h">Xray VLESS‑Reality</span>
<span class="muted fold-meta" id="xray-status-line"></span>
</span>
</summary>
<div class="panel-fold-body">
<p class="muted warp-intro">
Протокол для мест, где блокируют AmneziaWG: трафик выглядит как обычный HTTPS к сайту‑маскировке.
Ссылку <code class="inline">vless://</code> вставьте в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ. Нужен открытый TCP‑порт.
</p>
<form id="xray-install-form" class="instance-form">
<div class="instance-form-row">
<label for="xray-port">Порт (TCP)</label>
<input id="xray-port" type="number" min="1" max="65535" value="443">
</div>
<div class="instance-form-row">
<label for="xray-sni">Сайт‑маскировка</label>
<input id="xray-sni" type="text" value="www.google.com" autocomplete="off">
</div>
<button type="submit" class="btn primary">+ Установить Xray</button>
</form>
<div id="xray-manage" style="display:none">
<form id="xray-client-form" class="instance-form">
<div class="instance-form-row">
<label for="xray-client-name">Новый клиент</label>
<input id="xray-client-name" type="text" maxlength="64" placeholder="Имя" autocomplete="off">
</div>
<button type="submit" class="btn primary">+ Создать</button>
<button type="button" id="xray-remove" class="btn small warn">Удалить Xray</button>
</form>
<table class="clients-table"><tbody id="xray-rows"></tbody></table>
</div>
</div>
</details>
<details class="panel-fold" id="instances-panel" open> <details class="panel-fold" id="instances-panel" open>
<summary class="fold-summary"> <summary class="fold-summary">
<span class="fold-arrow" aria-hidden="true"></span> <span class="fold-arrow" aria-hidden="true"></span>
@@ -198,8 +242,10 @@
<label for="instance-variant">Протокол</label> <label for="instance-variant">Протокол</label>
<select id="instance-variant" class="form-select"> <select id="instance-variant" class="form-select">
<option value="awg2">AmneziaWG 2.0 (рекомендуется)</option> <option value="awg2">AmneziaWG 2.0 (рекомендуется)</option>
<option value="awg">AmneziaWG</option> <option value="awg31">AmneziaWG 3.1 — новейший, свежее приложение Amnezia</option>
<option value="legacy">AmneziaWG Legacy</option> <option value="awg3">AmneziaWG 3.0</option>
<option value="awg">AmneziaWG 1.x — роутеры Keenetic/OpenWrt, старые приложения</option>
<option value="legacy">WireGuard (Legacy) — любой WG-клиент, без обфускации</option>
</select> </select>
</div> </div>
<div class="instance-form-row"> <div class="instance-form-row">
@@ -208,7 +254,15 @@
</div> </div>
<button type="submit" class="btn primary">+ Развернуть инстанс</button> <button type="submit" class="btn primary">+ Развернуть инстанс</button>
</form> </form>
<p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов.</p> <p class="muted instance-hint">Создаёт серверный контейнер AmneziaWG из публичного образа, генерирует ключи и NAT. Затем в «Пользователях» выберите инстанс и создавайте клиентов. Можно держать несколько версий рядом на разных портах: 3.1/2.0 — для телефонов и ПК, 1.x или WireGuard — для роутеров. Не забудьте открыть UDP-порт в фаерволе хостинга.</p>
<form id="settings-form" class="instance-form">
<div class="instance-form-row">
<label for="settings-endpoint">Домен или IP сервера в конфигах клиентов</label>
<input id="settings-endpoint" type="text" placeholder="vpn.example.com — пусто: IP из установки" autocomplete="off">
</div>
<button type="submit" class="btn primary">Сохранить адрес</button>
</form>
<p id="settings-hint" class="muted instance-hint"></p>
</div> </div>
</details> </details>
@@ -359,7 +413,7 @@
<div class="support-line"> <div class="support-line">
<strong class="support-title">amnezia_web-PRO</strong> <strong class="support-title">amnezia_web-PRO</strong>
<span class="support-sep" aria-hidden="true">·</span> <span class="support-sep" aria-hidden="true">·</span>
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · amnezia_web-PRO</a> <a href="https://git.bobyrev.com/PCA/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ Git · amnezia_web-PRO</a>
<span class="support-sep" aria-hidden="true">·</span> <span class="support-sep" aria-hidden="true">·</span>
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a> <a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
<span class="support-sep" aria-hidden="true">·</span> <span class="support-sep" aria-hidden="true">·</span>
@@ -406,6 +460,6 @@
</div> </div>
</dialog> </dialog>
<script src="/app.js?v=1.2.28" type="module"></script> <script src="/app.js?v=1.4.2" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -8,6 +8,10 @@
--danger: #fb7185; --danger: #fb7185;
--ok: #4ade80; --ok: #4ade80;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45); --shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
--field: #0a0f16;
--text-soft: #cbd5f5;
--overlay: rgba(10, 15, 22, 0.92);
color-scheme: dark;
font-family: "DM Sans", system-ui, sans-serif; font-family: "DM Sans", system-ui, sans-serif;
} }
@@ -15,6 +19,28 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Светлая тема: те же переменные, переключатель в шапке (localStorage "theme"). */
html[data-theme="light"] {
--bg: #f4f6fb;
--card: #ffffff;
--line: rgba(15, 23, 42, 0.12);
--text: #0f172a;
--muted: #526077;
--accent: #0369a1;
--danger: #be123c;
--ok: #15803d;
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--field: #ffffff;
--text-soft: #1e293b;
--overlay: rgba(255, 255, 255, 0.94);
color-scheme: light;
}
html[data-theme="light"] .panel-promo-strip {
background: linear-gradient(90deg, #e0f2fe, #eef2ff);
border-bottom-color: rgba(3, 105, 161, 0.25);
}
html[data-theme="light"] .panel-promo-a { color: #92400e; }
body.page { body.page {
margin: 0; margin: 0;
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%), background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
@@ -123,7 +149,7 @@ h1 {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
padding: 1rem; padding: 1rem;
background: #0c121b; background: var(--field);
} }
.token-box label { .token-box label {
@@ -191,7 +217,7 @@ h1 {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
@@ -255,7 +281,7 @@ h1 {
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
@@ -265,7 +291,7 @@ h1 {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font-family: "JetBrains Mono", ui-monospace, monospace; font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
@@ -294,7 +320,7 @@ h1 {
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -407,7 +433,7 @@ h1 {
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
@@ -715,7 +741,7 @@ tr:last-child td {
.ip { .ip {
font-family: "JetBrains Mono", ui-monospace, monospace; font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
color: #dbeafe; color: var(--text-soft);
} }
.badge { .badge {
@@ -736,7 +762,7 @@ tr:last-child td {
.badge.off { .badge.off {
border-color: rgba(148, 163, 184, 0.35); border-color: rgba(148, 163, 184, 0.35);
color: #e2e8f0; color: var(--text-soft);
background: rgba(148, 163, 184, 0.06); background: rgba(148, 163, 184, 0.06);
} }
@@ -833,11 +859,11 @@ tr:last-child td {
.cascade-snippet { .cascade-snippet {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
background: #0a0f16; background: var(--field);
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 0.75rem 0.95rem; padding: 0.75rem 0.95rem;
border-radius: 12px; border-radius: 12px;
color: #cbd5f5; color: var(--text-soft);
font-size: 0.78rem; font-size: 0.78rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
margin: 0.5rem 0 0.85rem; margin: 0.5rem 0 0.85rem;
@@ -884,11 +910,11 @@ tr:last-child td {
.raw pre { .raw pre {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
background: #0a0f16; background: var(--field);
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 0.75rem; padding: 0.75rem;
border-radius: 12px; border-radius: 12px;
color: #cbd5f5; color: var(--text-soft);
font-size: 0.78rem; font-size: 0.78rem;
} }
@@ -927,7 +953,7 @@ tr:last-child td {
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #0a0f16; background: var(--field);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
@@ -975,7 +1001,7 @@ tr:last-child td {
margin-top: auto; margin-top: auto;
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem; padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: rgba(10, 15, 22, 0.92); background: var(--overlay);
} }
.support-line { .support-line {
@@ -1071,36 +1097,6 @@ tr:last-child td {
overflow: auto; overflow: auto;
} }
.edition-banner {
margin: 0 0 1rem;
padding: 0.85rem 1rem;
border-radius: 14px;
border: 1px solid rgba(125, 211, 252, 0.35);
background: linear-gradient(135deg, rgba(125, 211, 252, 0.12), rgba(94, 234, 212, 0.06));
}
.edition-banner.hidden {
display: none;
}
.edition-banner-inner {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: center;
justify-content: space-between;
}
.edition-banner-text {
flex: 1 1 280px;
}
.edition-banner-pitch {
margin: 0.35rem 0 0;
font-size: 0.88rem;
line-height: 1.45;
}
/* --- Telegram MTProto --- */ /* --- Telegram MTProto --- */
.mtproto-link-card { .mtproto-link-card {
margin: 0.65rem 0 0.85rem; margin: 0.65rem 0 0.85rem;
@@ -1227,3 +1223,12 @@ tr:last-child td {
.instance-empty { padding: 12px; } .instance-empty { padding: 12px; }
.inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; } .inst-src { font-size: 0.6rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 7px; vertical-align: middle; }
/* Светлая тема: пастельные надписи тёмной темы на белом не читаются */
html[data-theme="light"] .clock-zone-diff--accent,
html[data-theme="light"] .cascade-guide a,
html[data-theme="light"] .cascade-guide a:hover { color: #0369a1; }
html[data-theme="light"] .btn.warn { color: #9f1239; }
html[data-theme="light"] .status.err { color: #b91c1c; }
html[data-theme="light"] .badge.on { color: #166534; }
html[data-theme="light"] body.page { background: var(--bg); }

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deploy / remove an AmneziaWG server instance from public images — no Amnezia app. # Deploy / remove an AmneziaWG server instance from public images — no Amnezia app.
# Variants: awg2 (AmneziaWG 2.0, awg-go 2.0.0), awg (classic, awg-go 0.2.18), # Variants: awg31 (AmneziaWG 3.1), awg3 (3.0), awg2 (2.0, awg-go 2.0.0),
# legacy (kernel WireGuard, amnezia-wg). # awg (1.x for routers/old apps, awg-go 0.2.18), legacy (plain WireGuard, amnezia-wg).
# Usage: # Usage:
# awg-instance.sh create <variant> <port> [name] # awg-instance.sh create <variant> <port> [name]
# awg-instance.sh remove <name> # awg-instance.sh remove <name>
@@ -12,6 +12,9 @@ INSTANCES_DIR="${INSTANCES_DIR:-/opt/amnezia-instances}"
IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}" IMG_AWG2="${IMG_AWG2:-amneziavpn/amneziawg-go:2.0.0}"
IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}" IMG_AWG="${IMG_AWG:-amneziavpn/amneziawg-go:0.2.18}"
IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}" IMG_LEGACY="${IMG_LEGACY:-amneziavpn/amnezia-wg:latest}"
# 3.x pinned by digest: tags on Docker Hub get rewritten (tools inside differ from the tag name)
IMG_AWG3="${IMG_AWG3:-amneziavpn/amneziawg-go:3.0.20260805@sha256:8447c91637c37536dd99b8bbd4420c819ac9f330f047804197291625bfb0ea8a}"
IMG_AWG31="${IMG_AWG31:-amneziavpn/amneziawg-go:3.1.20260828@sha256:cbafc02b8373a83f428272db6d8001b37bc02e6211cbd8c0cb4e2e3759b12b72}"
err() { echo "ERROR: $*" >&2; exit 1; } err() { echo "ERROR: $*" >&2; exit 1; }
@@ -28,10 +31,12 @@ rand_magic() { # single large uint32 (AmneziaWG H header), NOT a range
image_for() { image_for() {
case "$1" in case "$1" in
awg31) echo "$IMG_AWG31" ;;
awg3) echo "$IMG_AWG3" ;;
awg2) echo "$IMG_AWG2" ;; awg2) echo "$IMG_AWG2" ;;
awg) echo "$IMG_AWG" ;; awg) echo "$IMG_AWG" ;;
legacy) echo "$IMG_LEGACY" ;; legacy) echo "$IMG_LEGACY" ;;
*) err "unknown variant: $1 (awg2|awg|legacy)" ;; *) err "unknown variant: $1 (awg31|awg3|awg2|awg|legacy)" ;;
esac esac
} }
@@ -63,7 +68,8 @@ cmd_create() {
[[ -n "$name" ]] || err "bad name" [[ -n "$name" ]] || err "bad name"
docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists" docker inspect "$name" >/dev/null 2>&1 && err "container $name already exists"
# port free? # port free?
if ss -lun 2>/dev/null | grep -qE "[:.]${port}\b"; then err "udp port $port busy"; fi # this script runs inside the panel container: its own `ss` does not see host ports, docker does
if docker ps --format '{{.Ports}}' | tr ',' '\n' | grep -qE ":${port}->[0-9]+/udp"; then err "udp port $port busy (другой контейнер)"; fi
echo "→ pulling $img" echo "→ pulling $img"
docker pull -q "$img" >/dev/null docker pull -q "$img" >/dev/null
@@ -87,22 +93,37 @@ cmd_create() {
{ {
echo "[Interface]" echo "[Interface]"
echo "PrivateKey = $priv" echo "PrivateKey = $priv"
echo "Address = ${net}.0/24" echo "Address = ${net}.1/24"
echo "ListenPort = $port" echo "ListenPort = $port"
if [[ "$variant" != "legacy" ]]; then if [[ "$variant" != "legacy" ]]; then
# 3.x header protection rejects junk sizes <= 12
local smin=15
echo "Jc = $(rand_range 3 10)" echo "Jc = $(rand_range 3 10)"
echo "Jmin = 10" echo "Jmin = 10"
echo "Jmax = 50" echo "Jmax = 50"
echo "S1 = $(rand_range 15 60)" echo "S1 = $(rand_range $smin 60)"
echo "S2 = $(rand_range 15 60)" echo "S2 = $(rand_range $smin 60)"
if [[ "$variant" == "awg2" ]]; then if [[ "$variant" != "awg" ]]; then
echo "S3 = $(rand_range 5 40)" echo "S3 = $(rand_range $smin 40)"
echo "S4 = $(rand_range 1 40)" echo "S4 = $(rand_range $smin 40)"
fi fi
echo "H1 = $(rand_magic)" echo "H1 = $(rand_magic)"
echo "H2 = $(rand_magic)" echo "H2 = $(rand_magic)"
echo "H3 = $(rand_magic)" echo "H3 = $(rand_magic)"
echo "H4 = $(rand_magic)" echo "H4 = $(rand_magic)"
if [[ "$variant" == awg3* ]]; then
echo "HeaderProtectionKey = $(docker run --rm "$img" awg genkey | tr -d '\r\n')"
echo "ContentPaddingAddition = 10-100"
echo "RekeyAfterTime = 100-120"
echo "RekeyTimeout = 3-7"
echo "RejectAfterTime = 150-180"
echo "KeepaliveTimeout = 5-15"
echo "MaxHandshakeAttempts = 15-20"
fi
if [[ "$variant" == "awg31" ]]; then
echo "RandomTrailers = on"
echo "DisableCookies = on"
fi
fi fi
} > "$conf" } > "$conf"
@@ -114,6 +135,13 @@ cmd_create() {
# start script run inside container # start script run inside container
local usimpl="" local usimpl=""
if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi if [[ "$variant" != "legacy" ]]; then usimpl="export WG_QUICK_USERSPACE_IMPLEMENTATION=amneziawg-go"; fi
# 3.x is userspace-only: a loaded amneziawg kernel module (2.0 on the same host) does not know 3.x keys
if [[ "$variant" == awg3* ]]; then
usimpl="$usimpl"$'\n'"$(cat <<'PATCH'
sed -i -e 's/if ! cmd ip link add "$INTERFACE" type amneziawg; then/if true; then/' -e 's|^\([[:space:]]*\)\[\[ -e /sys/module/amneziawg \]\].*|\1:|' /usr/bin/awg-quick
PATCH
)"
fi
cat > "$dir/start.sh" <<EOF cat > "$dir/start.sh" <<EOF
#!/bin/sh #!/bin/sh
set +e set +e
@@ -156,7 +184,7 @@ cmd_remove() {
} }
cmd_list() { cmd_list() {
docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-(awg2|awg|legacy)-|^amnezia-' || true docker ps --format '{{.Names}} {{.Ports}}' | grep -E '^amnezia-' || true
} }
case "${1:-}" in case "${1:-}" in

53
scripts/checks/audit2.py Normal file
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.
@@ -161,7 +176,8 @@ if [[ -z "${AWG_PROFILES:-}" ]] && [[ -f "${AWG_PROFILE_SNAPSHOT}" ]]; then
fi fi
fi fi
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | sort || true)" # Контейнеры, развёрнутые самой панелью (/opt/amnezia-instances/<имя>), панель знает сама — в AWG_PROFILES их не кладём.
__awg_names="$(docker ps --format '{{.Names}}' 2>/dev/null | awk '/^amnezia-awg/ { print }' | while read -r __n; do [[ -d "/opt/amnezia-instances/${__n}" ]] || echo "${__n}"; done | sort || true)"
__awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')" __awg_multi_count="$(printf '%s\n' "${__awg_names}" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
__profile_container_count=0 __profile_container_count=0
if [[ -n "${AWG_PROFILES:-}" ]]; then if [[ -n "${AWG_PROFILES:-}" ]]; then
@@ -282,20 +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)"
@@ -367,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"

607
server.js
View File

@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "child_process";
import crypto from "crypto"; import crypto from "crypto";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import dns from "dns";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs"; import { healClientsMeta, readClientsMeta, writeClientsMeta } from "./scripts/clients-meta.mjs";
@@ -50,36 +51,6 @@ function resolveUiHidden() {
const UI_HIDDEN = resolveUiHidden(); const UI_HIDDEN = resolveUiHidden();
const AMNEZIA_EDITION = (process.env.AMNEZIA_EDITION || "pro").trim().toLowerCase();
const IS_COMMUNITY = AMNEZIA_EDITION === "community";
const COMMUNITY_UPGRADE_URL =
process.env.COMMUNITY_UPGRADE_URL?.trim() ||
"https://github.com/andrey271192/amnezia_web-PRO";
const COMMUNITY_UPGRADE_PITCH =
process.env.COMMUNITY_UPGRADE_PITCH?.trim() ||
"В PRO: вкл/выкл клиентов, даты и расписание отключений, переименование, удаление, экспорт .conf, каскад, Cloudflare WARP, синхронизация времени хоста. Полная сборка открыта в публичном репозитории amnezia_web-PRO.";
function editionPayload() {
return {
tier: IS_COMMUNITY ? "community" : "pro",
readOnlyClients: IS_COMMUNITY,
upgradeUrl: IS_COMMUNITY ? COMMUNITY_UPGRADE_URL : null,
upgradePitch: IS_COMMUNITY ? COMMUNITY_UPGRADE_PITCH : null,
showDebugWg: !IS_COMMUNITY,
};
}
function effectiveUiHidden() {
if (!IS_COMMUNITY) return { ...UI_HIDDEN };
return {
users: UI_HIDDEN.users,
warp: true,
cascade: true,
mtproto: UI_HIDDEN.mtproto,
};
}
function parseProfilesFromEnv() { function parseProfilesFromEnv() {
const raw = process.env.AWG_PROFILES?.trim(); const raw = process.env.AWG_PROFILES?.trim();
const fallback = () => { const fallback = () => {
@@ -87,6 +58,7 @@ function parseProfilesFromEnv() {
return [ return [
{ {
id: "awg", id: "awg",
isDefault: true,
label: process.env.AWG_PROFILE_LABEL || "AmneziaWG", label: process.env.AWG_PROFILE_LABEL || "AmneziaWG",
container: process.env.AWG_CONTAINER || "amnezia-awg", container: process.env.AWG_CONTAINER || "amnezia-awg",
confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf", confPath: process.env.AWG_CONF_PATH || "/opt/amnezia/awg/awg0.conf",
@@ -137,25 +109,36 @@ function parseProfilesFromEnv() {
} }
const ENV_PROFILES = parseProfilesFromEnv(); const ENV_PROFILES = parseProfilesFromEnv();
let defaultProfileMissing = false; // set by createRuntime when the default profile resolves to nothing
const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances"; const INSTANCES_DIR = process.env.INSTANCES_DIR || "/opt/amnezia-instances";
const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`; const INSTANCES_FILE = `${process.env.DATA_DIR || "/data"}/instances.json`;
const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`; const INSTANCE_SCRIPT = `${process.env.APP_DIR || "/app"}/scripts/awg-instance.sh`;
const INSTANCE_VARIANTS = { const INSTANCE_VARIANTS = {
awg31: {
label: "AmneziaWG 3.1",
desc: "Новейшая версия: защита заголовков, случайные хвосты пакетов. Нужно свежее приложение Amnezia.",
iface: "awg0", binary: "awg",
},
awg3: {
label: "AmneziaWG 3.0",
desc: "Защита заголовков (HeaderProtectionKey), паддинг. Приложение Amnezia 4.8.x и новее.",
iface: "awg0", binary: "awg",
},
awg2: { awg2: {
label: "AmneziaWG 2.0", label: "AmneziaWG 2.0",
desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).", desc: "Новая версия протокола на основе awg-go. Расширенная обфускация (S3, S4).",
iface: "awg0", binary: "awg", iface: "awg0", binary: "awg",
}, },
awg: { awg: {
label: "AmneziaWG", label: "AmneziaWG 1.x (роутеры)",
desc: "Версия протокола на основе awg-go. Обфускация S1, S2.", desc: "Обфускация Jc/S1/S2/H1–H4 без S3/S4 — для Keenetic, OpenWrt и старых приложений.",
iface: "awg0", binary: "awg", iface: "awg0", binary: "awg",
}, },
legacy: { legacy: {
label: "AmneziaWG Legacy", label: "WireGuard (Legacy)",
desc: "Оригинальная версия на ядре WireGuard. Совместима с клиентами старых версий.", desc: "Чистый WireGuard без обфускации — любой WG-клиент и любые роутеры. Легко блокируется DPI.",
iface: "wg0", binary: "wg", iface: "wg0", binary: "wg",
}, },
}; };
@@ -219,10 +202,16 @@ function profileIdentityKey(p) {
// Effective profile set = env profiles + managed instances (deduped by id and target files). // Effective profile set = env profiles + managed instances (deduped by id and target files).
function getProfiles() { function getProfiles() {
const managed = loadManagedProfiles(); const managed = loadManagedProfiles();
const managedContainers = new Set(managed.map((m) => m.container));
const seenIds = new Set(); const seenIds = new Set();
const seenTargets = new Set(); const seenTargets = new Set();
const out = []; const out = [];
for (const p of [...ENV_PROFILES, ...managed]) { // install.sh used to put panel-managed containers into AWG_PROFILES too: the env copy then
// shadowed the managed one (wrong id, no variant). Managed instance wins.
const envProfiles = ENV_PROFILES.filter((p) => !managedContainers.has(p.container));
// Default profile (no AWG_PROFILES) that found no container: hide it once the panel has its own instances.
const env = managed.length && defaultProfileMissing ? envProfiles.filter((p) => !p.isDefault) : envProfiles;
for (const p of [...env, ...managed]) {
const id = String(p.id || "").trim(); const id = String(p.id || "").trim();
const target = profileIdentityKey(p); const target = profileIdentityKey(p);
if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue; if ((id && seenIds.has(id)) || (target !== "|||" && seenTargets.has(target))) continue;
@@ -438,23 +427,6 @@ function requireAuthOrExportToken(req, res, next) {
requireAuth(req, res, next); requireAuth(req, res, next);
} }
function rejectCommunityProOnly(res) {
res.status(403).json({
error:
"Доступно в версии PRO: управление клиентами, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста.",
upgradeRequired: true,
upgradeUrl: COMMUNITY_UPGRADE_URL,
});
}
function requireProTier(_req, res, next) {
if (!IS_COMMUNITY) {
next();
return;
}
rejectCommunityProOnly(res);
}
function runtimeFromExportRequest(req) { function runtimeFromExportRequest(req) {
const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : ""; const qPid = typeof req.query.profileId === "string" ? req.query.profileId.trim() : "";
const bodyPid = const bodyPid =
@@ -546,6 +518,30 @@ async function dockerRestartContainer(container) {
throw new Error("Контейнер не ответил после restart"); throw new Error("Контейнер не ответил после restart");
} }
// awg-quick brings up both AmneziaWG and plain WireGuard configs; old images only have wg-quick.
const QUICK_SH = "$(command -v awg-quick || echo wg-quick)";
/**
* Upstream for a real cascade: a client .conf of ANOTHER server (AWG or WG) becomes the "warp"
* interface of this instance. Routing of selected clients reuses the WARP machinery.
*/
function buildUpstreamConf(text) {
const sections = parseWireGuardSections(text);
const iface = sections.interface || {};
const peer = sections.peer || {};
if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) {
throw new Error("Нужен клиентский конфиг другого сервера: [Interface] с PrivateKey и [Peer] с PublicKey и Endpoint.");
}
const lines = ["[Interface]", `PrivateKey = ${iface.PrivateKey}`];
if (iface.Address) lines.push(`Address = ${String(iface.Address).split(",")[0].trim()}`);
for (const k of AWG_OBF_KEYS) if (iface[k]) lines.push(`${k} = ${iface[k]}`);
lines.push(`MTU = ${iface.MTU || 1280}`, "Table = off", "", "[Peer]", `PublicKey = ${peer.PublicKey}`);
if (peer.PresharedKey) lines.push(`PresharedKey = ${peer.PresharedKey}`);
lines.push("AllowedIPs = 0.0.0.0/0", `Endpoint = ${peer.Endpoint}`, "PersistentKeepalive = 25", "");
for (const l of lines) if (/['`$\\]/.test(l)) throw new Error("Недопустимые символы в конфиге.");
return lines.join("\n");
}
async function warpFileExists(rt, remotePath) { async function warpFileExists(rt, remotePath) {
try { try {
await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]); await execDocker(["exec", await rt.resolveContainer(), "test", "-f", remotePath]);
@@ -637,7 +633,7 @@ function buildWarpBootBlock(warpConf, ips) {
return ( return (
"# --- WARP-MANAGER BEGIN ---\n\n" + "# --- WARP-MANAGER BEGIN ---\n\n" +
`if [ -f '${warpConf}' ]; then\n` + `if [ -f '${warpConf}' ]; then\n` +
` wg-quick up '${warpConf}' || true\n` + ` ${QUICK_SH} up '${warpConf}' || true\n` +
` sleep 3\n` + ` sleep 3\n` +
`fi\n\n` + `fi\n\n` +
routing + routing +
@@ -745,7 +741,7 @@ async function warpSummaryForRt(rt) {
let wgShowWarp = ""; let wgShowWarp = "";
if (installed && running) { if (installed && running) {
try { try {
wgShowWarp = await rt.dockerExec("wg show warp 2>/dev/null || true"); wgShowWarp = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
} catch { } catch {
wgShowWarp = ""; wgShowWarp = "";
} }
@@ -800,7 +796,8 @@ async function containerHasFile(name, filePath) {
// Lets the panel work even if AWG_CONTAINER points at the wrong name // Lets the panel work even if AWG_CONTAINER points at the wrong name
// (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2"). // (e.g. configured "amnezia-awg" but Amnezia created "amnezia-awg2").
async function discoverAwgContainer(confPath, preferredName) { async function discoverAwgContainer(confPath, preferredName) {
const names = await listRunningContainerNames(); const managed = new Set(loadManagedProfiles().map((m) => m.container));
const names = (await listRunningContainerNames()).filter((n) => n === preferredName || !managed.has(n));
const score = (n) => { const score = (n) => {
if (preferredName && n === preferredName) return 0; if (preferredName && n === preferredName) return 0;
if (/^amnezia-?awg/i.test(n)) return 1; if (/^amnezia-?awg/i.test(n)) return 1;
@@ -852,6 +849,7 @@ function createRuntime(profile) {
if (profile.container) { if (profile.container) {
for (const candidate of confCandidates) { for (const candidate of confCandidates) {
if (await containerHasFile(profile.container, candidate)) { if (await containerHasFile(profile.container, candidate)) {
if (profile.isDefault) defaultProfileMissing = false;
resolvedContainer = profile.container; resolvedContainer = profile.container;
return resolvedContainer; return resolvedContainer;
} }
@@ -862,6 +860,7 @@ function createRuntime(profile) {
found = await discoverAwgContainer(candidate, profile.container); found = await discoverAwgContainer(candidate, profile.container);
if (found) break; if (found) break;
} }
if (profile.isDefault) defaultProfileMissing = !found;
if (found) { if (found) {
resolvedContainer = found; resolvedContainer = found;
if (found !== profile.container) { if (found !== profile.container) {
@@ -996,7 +995,7 @@ function createRuntime(profile) {
const activeIface = ifaceFromConfPath(activeConfPath, iface); const activeIface = ifaceFromConfPath(activeConfPath, iface);
const activeBinary = await resolveWgBinary(); const activeBinary = await resolveWgBinary();
await dockerExec( await dockerExec(
`wg-quick strip '${activeConfPath}' > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf` `(awg-quick strip '${activeConfPath}' 2>/dev/null || wg-quick strip '${activeConfPath}') > /tmp/wg-admin-strip.conf && ${activeBinary} syncconf ${activeIface} /tmp/wg-admin-strip.conf`
); );
} }
@@ -1186,26 +1185,45 @@ function defaultPeerClientName(peer) {
return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`; return ip ? `Клиент ${ip}` : `Peer ${String(peer?.publicKey || "").slice(0, 10)}…`;
} }
/** Совпадает с defaults Amnezia Desktop (protocolConstants awg, desktop MTU). */
const AWG_EXPORT_DEFAULTS = { // Client-side obfuscation keys, in the order Amnezia writes them. 3.x keys must match the server.
Jc: "3", const AWG_OBF_KEYS = [
Jmin: "10", "Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5",
Jmax: "30", "HeaderProtectionKey", "ContentPaddingAddition", "RekeyAfterTime", "RekeyTimeout", "RejectAfterTime",
S1: "15", "KeepaliveTimeout", "MaxHandshakeAttempts", "RandomTrailers", "DisableCookies",
S2: "18", ];
S3: "20", const AWG_OBF_ALIASES = {
S4: "23", Jc: ["junk_packet_count", "junkPacketCount"], Jmin: ["junk_packet_min_size", "junkPacketMinSize"],
H1: "1020325451", Jmax: ["junk_packet_max_size", "junkPacketMaxSize"], S1: ["init_packet_junk_size", "initPacketJunkSize"],
H2: "3288052141", S2: ["response_packet_junk_size", "responsePacketJunkSize"], S3: ["cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize"],
H3: "1766607858", S4: ["transport_packet_junk_size", "transportPacketJunkSize"], H1: ["init_packet_magic_header", "initPacketMagicHeader"],
H4: "2528465083", H2: ["response_packet_magic_header", "responsePacketMagicHeader"], H3: ["underload_packet_magic_header", "underloadPacketMagicHeader"],
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>", H4: ["transport_packet_magic_header", "transportPacketMagicHeader"],
I2: "", I1: ["special_junk_1", "specialJunk1"], I2: ["special_junk_2", "specialJunk2"], I3: ["special_junk_3", "specialJunk3"],
I3: "", I4: ["special_junk_4", "specialJunk4"], I5: ["special_junk_5", "specialJunk5"],
I4: "", HeaderProtectionKey: ["header_protection_key"],
I5: "",
}; };
/**
* Obfuscation lines for a client .conf. The server [Interface] is the source of truth: a key the
* server does not have (e.g. S3/S4 on AWG 1.x) must not appear in the client, otherwise the
* handshake passes but data packets are dropped. Keys only in last_config (I1..I5 written by the
* Amnezia app) are kept. Returns "" for plain WireGuard servers.
*/
function awgObfuscationLines(lc, ifaceMap) {
if (!ifaceMap.Jc && !pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount")) return "";
const lines = [];
for (const k of AWG_OBF_KEYS) {
const isServerKey = !/^I\d$/.test(k);
let v = isServerKey ? ifaceMap[k] : undefined;
if (v == null || String(v).trim() === "") v = pickLc(lc, k, ...(AWG_OBF_ALIASES[k] || []));
if (!isServerKey && (v == null || String(v).trim() === "")) v = ifaceMap[k];
if (isServerKey && ifaceMap.Jc && (ifaceMap[k] == null || ifaceMap[k] === "")) v = undefined;
if (v != null && String(v).trim() !== "") lines.push(`${k} = ${String(v).trim()}`);
}
return lines.length ? `${lines.join("\n")}\n` : "";
}
function parseLastConfigFromClientRow(row) { function parseLastConfigFromClientRow(row) {
const ud = row?.userData; const ud = row?.userData;
if (!ud || typeof ud !== "object") return null; if (!ud || typeof ud !== "object") return null;
@@ -1490,8 +1508,28 @@ function tunnelClientIpv4(peer, lc, row) {
); );
} }
// Panel settings editable from the UI (domain for client Endpoint etc.).
const SETTINGS_FILE = path.join(process.env.DATA_DIR || "/data", "settings.json");
function loadPanelSettings() {
try {
const o = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
return o && typeof o === "object" ? o : {};
} catch {
return {};
}
}
function savePanelSettings(next) {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2));
}
/** Domain/IP set in the UI wins over CLIENT_CONFIG_ENDPOINT (installer puts the raw IP there). */
function configuredEndpointHost() {
const ui = String(loadPanelSettings().endpointHost || "").trim();
return ui || process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "";
}
function resolveExportEndpointHost(lc, req) { function resolveExportEndpointHost(lc, req) {
const env = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); const env = configuredEndpointHost();
if (env) return env; if (env) return env;
const hn = pickLc(lc, "hostName", "hostname", "host"); const hn = pickLc(lc, "hostName", "hostname", "host");
if (hn && String(hn).trim()) return String(hn).trim(); if (hn && String(hn).trim()) return String(hn).trim();
@@ -1546,70 +1584,11 @@ async function buildClientConfExport(rt, lc, ifaceMap, req, row, conf) {
const mtuVal = pickLc(lc, "mtu", "MTU"); const mtuVal = pickLc(lc, "mtu", "MTU");
const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : ""; const mtuLine = mtuVal ? `MTU = ${String(mtuVal).trim()}\n` : "";
if (activeBinary === "awg") {
const Jc = String(pickLc(lc, "Jc", "junk_packet_count", "junkPacketCount") ?? AWG_EXPORT_DEFAULTS.Jc);
const Jmin = String(pickLc(lc, "Jmin", "junk_packet_min_size", "junkPacketMinSize") ?? AWG_EXPORT_DEFAULTS.Jmin);
const Jmax = String(pickLc(lc, "Jmax", "junk_packet_max_size", "junkPacketMaxSize") ?? AWG_EXPORT_DEFAULTS.Jmax);
const S1 = String(pickLc(lc, "S1", "init_packet_junk_size", "initPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S1);
const S2 = String(pickLc(lc, "S2", "response_packet_junk_size", "responsePacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S2);
const S3 = String(
pickLc(lc, "S3", "cookie_reply_packet_junk_size", "cookieReplyPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S3,
);
const S4 = String(
pickLc(lc, "S4", "transport_packet_junk_size", "transportPacketJunkSize") ?? AWG_EXPORT_DEFAULTS.S4,
);
const H1 = String(pickLc(lc, "H1", "init_packet_magic_header", "initPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H1);
const H2 = String(
pickLc(lc, "H2", "response_packet_magic_header", "responsePacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H2,
);
const H3 = String(
pickLc(lc, "H3", "underload_packet_magic_header", "underloadPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H3,
);
const H4 = String(
pickLc(lc, "H4", "transport_packet_magic_header", "transportPacketMagicHeader") ?? AWG_EXPORT_DEFAULTS.H4,
);
const I1 = String(pickLc(lc, "I1", "special_junk_1", "specialJunk1") ?? AWG_EXPORT_DEFAULTS.I1);
const I2 = String(pickLc(lc, "I2", "special_junk_2", "specialJunk2") ?? AWG_EXPORT_DEFAULTS.I2);
const I3 = String(pickLc(lc, "I3", "special_junk_3", "specialJunk3") ?? AWG_EXPORT_DEFAULTS.I3);
const I4 = String(pickLc(lc, "I4", "special_junk_4", "specialJunk4") ?? AWG_EXPORT_DEFAULTS.I4);
const I5 = String(pickLc(lc, "I5", "special_junk_5", "specialJunk5") ?? AWG_EXPORT_DEFAULTS.I5);
const iLines = [
["I1", I1], ["I2", I2], ["I3", I3], ["I4", I4], ["I5", I5],
]
.filter(([, v]) => String(v ?? "").trim() !== "")
.map(([k, v]) => `${k} = ${v}`)
.join("\n");
const iBlock = iLines ? `${iLines}\n` : "";
return `[Interface]
Address = ${tunnelIp}/32
DNS = ${dns1}, ${dns2}
PrivateKey = ${priv.trim()}
Jc = ${Jc}
Jmin = ${Jmin}
Jmax = ${Jmax}
S1 = ${S1}
S2 = ${S2}
S3 = ${S3}
S4 = ${S4}
H1 = ${H1}
H2 = ${H2}
H3 = ${H3}
H4 = ${H4}
${iBlock}${mtuLine}[Peer]
PublicKey = ${String(serverPub).trim()}
PresharedKey = ${String(psk).trim()}
AllowedIPs = ${peerAllowed}
Endpoint = ${endpointHost}:${port}
PersistentKeepalive = ${keepAlive}
`;
}
return `[Interface] return `[Interface]
Address = ${tunnelIp}/32 Address = ${tunnelIp}/32
DNS = ${dns1}, ${dns2} DNS = ${dns1}, ${dns2}
PrivateKey = ${priv.trim()} PrivateKey = ${priv.trim()}
${mtuLine}[Peer] ${awgObfuscationLines(lc, ifaceMap)}${mtuLine}[Peer]
PublicKey = ${String(serverPub).trim()} PublicKey = ${String(serverPub).trim()}
PresharedKey = ${String(psk).trim()} PresharedKey = ${String(psk).trim()}
AllowedIPs = ${peerAllowed} AllowedIPs = ${peerAllowed}
@@ -1652,7 +1631,7 @@ async function awgGenKeypair(rt) {
} }
function obfuscationFieldsFromServerHead(ifaceMap) { function obfuscationFieldsFromServerHead(ifaceMap) {
const keys = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4", "H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"]; const keys = AWG_OBF_KEYS;
const out = {}; const out = {};
for (const k of keys) { for (const k of keys) {
const v = ifaceMap[k]; const v = ifaceMap[k];
@@ -2080,9 +2059,6 @@ if (UI_HIDDEN.users || UI_HIDDEN.warp || UI_HIDDEN.cascade || UI_HIDDEN.mtproto)
`UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`, `UI_HIDDEN: users=${UI_HIDDEN.users} warp=${UI_HIDDEN.warp} cascade=${UI_HIDDEN.cascade} mtproto=${UI_HIDDEN.mtproto}`,
); );
} }
if (IS_COMMUNITY) {
console.warn(`Редакция community (только просмотр клиентов). PRO: ${COMMUNITY_UPGRADE_URL}`);
}
app.use(express.json({ limit: "2mb" })); app.use(express.json({ limit: "2mb" }));
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
@@ -2128,15 +2104,6 @@ app.get("/api/server-time", requireAuth, (req, res) => {
}); });
app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => { app.get("/api/time-sync-capabilities", requireAuth, (_req, res) => {
if (IS_COMMUNITY) {
res.json({
hostTimeSync: false,
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
serverClockTimeZone: resolveServerClockTimeZone(),
communityBlocked: true,
});
return;
}
res.json({ res.json({
hostTimeSync: hostTimeSyncConfigured(), hostTimeSync: hostTimeSyncConfigured(),
sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1", sshHost: process.env.TIME_SYNC_SSH_HOST?.trim() || "172.17.0.1",
@@ -2154,7 +2121,7 @@ function mtprotoLogsTailPayload(req) {
} }
app.get("/api/mtproto/status", requireAuth, (req, res) => { app.get("/api/mtproto/status", requireAuth, (req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
const snap = mtprotoSnapshot(hostFromRequest(req)); const snap = mtprotoSnapshot(hostFromRequest(req));
@@ -2170,21 +2137,21 @@ app.get("/api/mtproto/status", requireAuth, (req, res) => {
}); });
app.get("/api/mtproto/logs", requireAuth, (req, res) => { app.get("/api/mtproto/logs", requireAuth, (req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
res.json(mtprotoLogsTailPayload(req)); res.json(mtprotoLogsTailPayload(req));
}); });
app.get("/api/mtproto/tail", requireAuth, (req, res) => { app.get("/api/mtproto/tail", requireAuth, (req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
res.json(mtprotoLogsTailPayload(req)); res.json(mtprotoLogsTailPayload(req));
}); });
app.post("/api/mtproto/install", requireAuth, (req, res) => { app.post("/api/mtproto/install", requireAuth, (req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) { if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(MTPRO_CONTAINER)) {
@@ -2280,7 +2247,7 @@ app.post("/api/mtproto/install", requireAuth, (req, res) => {
}); });
app.post("/api/mtproto/remove", requireAuth, (_req, res) => { app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000); const r = dockerSpawnSync(["rm", "-f", MTPRO_CONTAINER], 120_000);
@@ -2294,7 +2261,7 @@ app.post("/api/mtproto/remove", requireAuth, (_req, res) => {
}); });
app.post("/api/mtproto/restart", requireAuth, (_req, res) => { app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
if (effectiveUiHidden().mtproto) { if (UI_HIDDEN.mtproto) {
return res.status(403).json({ error: MSG_UI_MTProto_OFF }); return res.status(403).json({ error: MSG_UI_MTProto_OFF });
} }
const snap = mtprotoSnapshot(); const snap = mtprotoSnapshot();
@@ -2309,7 +2276,7 @@ app.post("/api/mtproto/restart", requireAuth, (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) => { app.post("/api/sync-host-time", requireAuth, async (req, res) => {
if (!hostTimeSyncConfigured()) { if (!hostTimeSyncConfigured()) {
return res.status(503).json({ return res.status(503).json({
error: error:
@@ -2341,7 +2308,7 @@ app.post("/api/sync-host-time", requireAuth, requireProTier, async (req, res) =>
} }
}); });
app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) => { app.post("/api/warp/host-setup", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp) { if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF }); return res.status(403).json({ error: MSG_UI_WARP_OFF });
} }
@@ -2385,6 +2352,202 @@ app.post("/api/warp/host-setup", requireAuth, requireProTier, async (req, res) =
} }
}); });
// ---------- Xray VLESS-Reality ----------
// Layout matches the official Amnezia client (xray_*.key + clientsTable), so the app sees the same server.
const XRAY_CONTAINER = "amnezia-xray";
const XRAY_IMAGE = "amnezia-xray";
const XRAY_DIR = "/opt/amnezia/xray";
const XRAY_VERSION = "v26.3.27";
const XRAY_DOCKERFILE = `FROM alpine:3.20
RUN apk add --no-cache curl unzip bash openssl dumb-init && \\
curl -fsSL -o /root/xray.zip https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-64.zip && \\
unzip /root/xray.zip xray -d /usr/bin/ && chmod +x /usr/bin/xray && rm /root/xray.zip && mkdir -p ${XRAY_DIR}
ENTRYPOINT ["dumb-init", "/usr/bin/xray", "-config", "${XRAY_DIR}/server.json"]
`;
function xrayExec(script) {
return dockerExecStdin(XRAY_CONTAINER, script);
}
function xrayShellQuote(s) {
return `'${String(s).replace(/'/g, `'\\''`)}'`;
}
async function xrayReadFile(name) {
try {
return (await xrayExec(`cat ${XRAY_DIR}/${name} 2>/dev/null`)).stdout;
} catch {
return "";
}
}
async function xrayWriteFile(name, text) {
await execDocker(["exec", "-i", XRAY_CONTAINER, "sh", "-c", `cat > ${XRAY_DIR}/${name}`], text);
}
function xrayInspect() {
const r = dockerSpawnSync(["inspect", "--format", "{{.State.Running}}", XRAY_CONTAINER], 15_000);
return { installed: r.code === 0, running: r.stdout.trim() === "true" };
}
async function xrayState() {
const server = JSON.parse((await xrayReadFile("server.json")) || "{}");
const ib = (server.inbounds || []).find((i) => i.protocol === "vless");
if (!ib) throw new Error("В server.json нет VLESS-входа.");
let table = [];
try {
table = JSON.parse((await xrayReadFile("clientsTable")) || "[]");
} catch {}
if (!Array.isArray(table)) table = [];
return {
server,
ib,
table,
pub: (await xrayReadFile("xray_public.key")).trim(),
shortId: (await xrayReadFile("xray_short_id.key")).trim() || ib.streamSettings?.realitySettings?.shortIds?.[0] || "",
};
}
async function xraySave(st) {
// Enabled = present in server.json; disabled clients live only in clientsTable.
st.ib.settings.clients = st.table
.filter((r) => !r.userData?.disabled)
.map((r) => ({ id: r.clientId, flow: "xtls-rprx-vision" }));
await xrayWriteFile("server.json", JSON.stringify(st.server, null, 2));
await xrayWriteFile("clientsTable", JSON.stringify(st.table, null, 2));
await execDocker(["restart", XRAY_CONTAINER]);
}
function xrayLink(st, row, host) {
const rs = st.ib.streamSettings.realitySettings;
const q = new URLSearchParams({
type: "tcp",
security: "reality",
pbk: st.pub,
fp: "chrome",
sni: rs.serverNames[0],
sid: st.shortId,
spx: "/",
flow: "xtls-rprx-vision",
encryption: "none",
});
const name = encodeURIComponent(row.userData?.clientName || "client");
return `vless://${row.clientId}@${host}:${st.ib.port}?${q}#${name}`;
}
function xrayHost(req) {
return configuredEndpointHost() || hostFromRequest(req) || "SERVER_IP";
}
app.get("/api/xray/status", requireAuth, async (req, res) => {
const s = xrayInspect();
if (!s.running) return res.json({ ...s, clients: [] });
try {
const st = await xrayState();
const host = xrayHost(req);
res.json({
...s,
port: st.ib.port,
sni: st.ib.streamSettings?.realitySettings?.serverNames?.[0] || "",
clients: st.table.map((r) => ({
id: r.clientId,
name: r.userData?.clientName || r.clientId,
enabled: !r.userData?.disabled,
created: r.userData?.creationDate || "",
link: xrayLink(st, r, host),
})),
});
} catch (e) {
res.json({ ...s, clients: [], error: String(e.message || e) });
}
});
app.post("/api/xray/install", requireAuth, async (req, res) => {
const port = Number.parseInt(String(req.body?.port || "443"), 10);
const sni = String(req.body?.sni || "www.google.com").trim();
if (!(port > 0 && port < 65536)) return res.status(400).json({ error: "Неверный порт." });
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(sni)) return res.status(400).json({ error: "Неверный сайт-маскировка (SNI)." });
if (xrayInspect().installed) return res.status(409).json({ error: "Xray уже установлен. Сначала удалите его." });
const busy = dockerSpawnSync(["ps", "--format", "{{.Names}} {{.Ports}}"], 15_000).stdout
.split("\n")
.find((l) => new RegExp(`:${port}->\\d+/tcp`).test(l));
if (busy) return res.status(409).json({ error: `TCP-порт ${port} занят: ${busy.split(" ")[0]}` });
try {
await execDocker(["build", "-t", XRAY_IMAGE, "-"], XRAY_DOCKERFILE);
const kp = (await execDocker(["run", "--rm", "--entrypoint", "/usr/bin/xray", XRAY_IMAGE, "x25519"])).stdout;
const priv = (kp.match(/Private\s*key:\s*(\S+)/i) || kp.match(/PrivateKey:\s*(\S+)/i) || [])[1];
const pub = (kp.match(/(?:Public\s*key|Password)[^:\n]*:\s*(\S+)/i) || [])[1];
if (!priv || !pub) throw new Error(`Не удалось получить ключи x25519: ${kp}`);
const shortId = crypto.randomBytes(8).toString("hex");
const server = {
log: { loglevel: "error" },
inbounds: [
{
port,
protocol: "vless",
settings: { clients: [], decryption: "none" },
streamSettings: {
network: "tcp",
security: "reality",
realitySettings: { dest: `${sni}:443`, serverNames: [sni], privateKey: priv, shortIds: [shortId] },
},
},
],
outbounds: [{ protocol: "freedom" }],
};
const files = {
"server.json": JSON.stringify(server, null, 2),
"xray_private.key": `${priv}\n`,
"xray_public.key": `${pub}\n`,
"xray_short_id.key": `${shortId}\n`,
"xray_uuid.key": "\n",
clientsTable: "[]",
};
const script = ["set -e", `mkdir -p /x`]
.concat(Object.entries(files).map(([n, t]) => `printf '%s' ${xrayShellQuote(t)} > /x/${n}`))
.join("\n");
await execDocker(["run", "--rm", "-i", "-v", `${XRAY_DIR}:/x`, "--entrypoint", "sh", XRAY_IMAGE, "-s"], script);
await execDocker([
"run", "-d", "--restart", "always", "--name", XRAY_CONTAINER,
"-p", `${port}:${port}/tcp`, "-v", `${XRAY_DIR}:${XRAY_DIR}`, XRAY_IMAGE,
]);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/xray/remove", requireAuth, async (_req, res) => {
dockerSpawnSync(["rm", "-f", XRAY_CONTAINER], 60_000);
// ponytail: keys and clientsTable stay in /opt/amnezia/xray on the host; a reinstall regenerates them.
res.json({ ok: true });
});
app.post("/api/xray/clients/create", requireAuth, async (req, res) => {
const name = String(req.body?.name || "").trim().slice(0, 64);
if (!name) return res.status(400).json({ error: "Укажите имя клиента." });
try {
const st = await xrayState();
const row = { clientId: crypto.randomUUID(), userData: { clientName: name, creationDate: new Date().toString() } };
st.table.push(row);
await xraySave(st);
res.json({ ok: true, link: xrayLink(st, row, xrayHost(req)) });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/xray/clients/update", requireAuth, async (req, res) => {
const id = String(req.body?.id || "");
const action = String(req.body?.action || "");
try {
const st = await xrayState();
const row = st.table.find((r) => r.clientId === id);
if (!row) return res.status(404).json({ error: "Клиент не найден." });
if (action === "delete") st.table = st.table.filter((r) => r !== row);
else if (action === "enable" || action === "disable") {
row.userData = { ...row.userData, disabled: action === "disable" };
if (!row.userData.disabled) delete row.userData.disabled;
} else return res.status(400).json({ error: "Неизвестное действие." });
await xraySave(st);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: String(e.message || e) });
}
});
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
const pw = req.body?.password; const pw = req.body?.password;
if (typeof pw !== "string" || !pw) { if (typeof pw !== "string" || !pw) {
@@ -2427,14 +2590,43 @@ app.post("/api/change-password", requireAuth, (req, res) => {
res.json({ ok: true, message: "Пароль изменён. Войдите снова." }); res.json({ ok: true, message: "Пароль изменён. Войдите снова." });
}); });
app.get("/api/settings", requireAuth, (_req, res) => {
const st = loadPanelSettings();
res.json({
endpointHost: st.endpointHost || "",
envEndpoint: process.env.CLIENT_CONFIG_ENDPOINT?.trim() || "",
effectiveEndpoint: configuredEndpointHost(),
});
});
app.post("/api/settings", requireAuth, async (req, res) => {
const raw = String(req.body?.endpointHost ?? "").trim();
let host = "";
if (raw) {
try {
host = assertCascadeEndpointHost(raw);
} catch (e) {
return res.status(400).json({ error: String(e.message || e) });
}
if (!/^[A-Za-z0-9.-]+$/.test(host) && !/^[0-9a-fA-F:]+$/.test(host)) {
return res.status(400).json({ error: "Домен или IP: только латиница, цифры, точки и дефисы." });
}
}
let resolved = null;
if (host && !/^[\d.]+$/.test(host) && host.includes(".")) {
try {
resolved = (await dns.promises.lookup(host, { all: true })).map((a) => a.address);
} catch {
return res.status(400).json({ error: `Домен ${host} не резолвится. Создайте A-запись на IP этого сервера и повторите.` });
}
}
savePanelSettings({ ...loadPanelSettings(), endpointHost: host });
res.json({ ok: true, endpointHost: host, resolved });
});
app.get("/api/protocols", requireAuth, (req, res) => { app.get("/api/protocols", requireAuth, (req, res) => {
const rt = runtimeForRequest(req); const rt = runtimeForRequest(req);
const hintSingle = const hintSingle = "";
getProfiles().length < 2
? IS_COMMUNITY
? "Один инстанс в интерфейсе. Несколько контейнеров и профиль AWG_PROFILES — в полной панели PRO."
: "Сейчас один инстанс: при установке не передали AWG_PROFILES или не восстановился снимок. Задайте JSON профилей и запустите install.sh — он сохранится в /root/amnezia-admin.awg-profiles.json."
: "";
res.json({ res.json({
currentId: rt.profile.id, currentId: rt.profile.id,
currentLabel: rt.profile.label, currentLabel: rt.profile.label,
@@ -2445,7 +2637,6 @@ app.get("/api/protocols", requireAuth, (req, res) => {
})), })),
singleProfile: getProfiles().length < 2, singleProfile: getProfiles().length < 2,
profilesPersistHint: hintSingle, profilesPersistHint: hintSingle,
edition: editionPayload(),
}); });
}); });
@@ -2555,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();
@@ -2658,14 +2848,6 @@ app.get("/api/clients/source-report", requireAuth, async (req, res) => {
}); });
async function serveClientConfigExport(req, res) { async function serveClientConfigExport(req, res) {
if (IS_COMMUNITY) {
res.status(403).json({
error: "Экспорт .conf доступен в версии PRO.",
upgradeRequired: true,
upgradeUrl: COMMUNITY_UPGRADE_URL,
});
return;
}
const tokenOk = const tokenOk =
req.method === "GET" && req.method === "GET" &&
verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : ""); verifyExportQueryToken(typeof req.query.token === "string" ? req.query.token : "");
@@ -2745,7 +2927,7 @@ app.post("/api/clients/export-config", requireAuth, (req, res) => {
* Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config, * Новый клиент для каскада: генерирует ключи, добавляет peer на сервер, сохраняет last_config,
* отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел). * отдаёт .conf с Endpoint = endpointHost:endpointPort (ваш промежуточный узел).
*/ */
app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/create", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
try { try {
await rt.backupRemoteFiles(); await rt.backupRemoteFiles();
@@ -2757,7 +2939,7 @@ app.post("/api/clients/create", requireAuth, requireProTier, async (req, res) =>
} }
const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp); const tunnelIp = normalizeCascadeTunnelIp(conf, ifaceMap, req.body?.tunnelIp);
const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN; const listenPort = ifaceMap.ListenPort ? Number(ifaceMap.ListenPort) : NaN;
const envHost = process.env.CLIENT_CONFIG_ENDPOINT?.trim(); const envHost = configuredEndpointHost();
const hdrHost = const hdrHost =
typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : ""; typeof req.headers.host === "string" ? req.headers.host.split(":")[0].trim() : "";
const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : ""); const endpointHost = envHost || (hdrHost && hdrHost !== "localhost" ? hdrHost : "");
@@ -2838,7 +3020,7 @@ AllowedIPs = ${tunnelIp}/32
} }
}); });
app.post("/api/clients/create-cascade", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/create-cascade", requireAuth, async (req, res) => {
if (UI_HIDDEN.cascade) { if (UI_HIDDEN.cascade) {
return res.status(403).json({ error: MSG_UI_CASCADE_OFF }); return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
} }
@@ -2954,7 +3136,7 @@ AllowedIPs = ${tunnelIp}/32
} }
}); });
app.post("/api/clients/import-config", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/import-config", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
try { try {
const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? ""; const raw = req.body?.configText ?? req.body?.backupText ?? req.body?.text ?? "";
@@ -2980,7 +3162,7 @@ app.post("/api/clients/import-config", requireAuth, requireProTier, async (req,
} }
}); });
app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => { app.post("/api/warp/start", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp) { if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF }); return res.status(403).json({ error: MSG_UI_WARP_OFF });
} }
@@ -2992,8 +3174,8 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
}); });
} }
try { try {
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
await rt.dockerExec(`wg-quick up '${rt.profile.warpConf}'`); await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}'`);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -3001,7 +3183,32 @@ app.post("/api/warp/start", requireAuth, requireProTier, async (req, res) => {
} }
}); });
app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => { app.post("/api/cascade/upstream", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp || UI_HIDDEN.cascade) {
return res.status(403).json({ error: MSG_UI_CASCADE_OFF });
}
const rt = runtimeForRequest(req);
try {
const conf = buildUpstreamConf(String(req.body?.configText || ""));
await rt.backupRemoteFiles();
await rt.dockerExec(`mkdir -p '${rt.profile.warpDir}'`);
await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
await rt.dockerWriteFile(rt.profile.warpConf, conf);
await rt.dockerExec(`chmod 600 '${rt.profile.warpConf}'`);
const up = await rt.dockerExec(`${QUICK_SH} up '${rt.profile.warpConf}' 2>&1`);
const selected = await warpLoadSelectedIps(rt);
await warpApplyRouting(rt, selected);
await warpPatchStartSh(rt, selected);
await new Promise((r) => setTimeout(r, 3000));
const show = await rt.dockerExec("awg show warp 2>/dev/null || wg show warp 2>/dev/null || true");
res.json({ ok: true, handshake: /latest handshake/.test(show), show: show.slice(0, 2000), output: up.slice(-1500) });
} catch (e) {
console.error(e);
res.status(400).json({ error: String(e.message || e).slice(0, 1500) });
}
});
app.post("/api/warp/stop", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp) { if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF }); return res.status(403).json({ error: MSG_UI_WARP_OFF });
} }
@@ -3010,7 +3217,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
return res.status(400).json({ error: "WARP не установлен." }); return res.status(400).json({ error: "WARP не установлен." });
} }
try { try {
await rt.dockerExec(`wg-quick down '${rt.profile.warpConf}' 2>/dev/null || true`); await rt.dockerExec(`${QUICK_SH} down '${rt.profile.warpConf}' 2>/dev/null || true`);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -3018,7 +3225,7 @@ app.post("/api/warp/stop", requireAuth, requireProTier, async (req, res) => {
} }
}); });
app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => { app.post("/api/warp/routing", requireAuth, async (req, res) => {
if (UI_HIDDEN.warp) { if (UI_HIDDEN.warp) {
return res.status(403).json({ error: MSG_UI_WARP_OFF }); return res.status(403).json({ error: MSG_UI_WARP_OFF });
} }
@@ -3057,7 +3264,7 @@ app.post("/api/warp/routing", requireAuth, requireProTier, async (req, res) => {
} }
}); });
app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/disable", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;
if (!clientId) return res.status(400).json({ error: "clientId required" }); if (!clientId) return res.status(400).json({ error: "clientId required" });
@@ -3080,7 +3287,7 @@ app.post("/api/clients/disable", requireAuth, requireProTier, async (req, res) =
} }
}); });
app.post("/api/clients/enable", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/enable", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;
if (!clientId) return res.status(400).json({ error: "clientId required" }); if (!clientId) return res.status(400).json({ error: "clientId required" });
@@ -3130,7 +3337,7 @@ AllowedIPs = ${ips}`;
} }
}); });
app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/disconnect-date", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;
if (!clientId) return res.status(400).json({ error: "clientId required" }); if (!clientId) return res.status(400).json({ error: "clientId required" });
@@ -3174,7 +3381,7 @@ app.post("/api/clients/disconnect-date", requireAuth, requireProTier, async (req
} }
}); });
app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/rename", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;
const rawName = req.body?.name ?? req.body?.clientName; const rawName = req.body?.name ?? req.body?.clientName;
@@ -3206,7 +3413,7 @@ app.post("/api/clients/rename", requireAuth, requireProTier, async (req, res) =>
} }
}); });
app.post("/api/clients/delete", requireAuth, requireProTier, async (req, res) => { app.post("/api/clients/delete", requireAuth, async (req, res) => {
const rt = runtimeFromExportRequest(req); const rt = runtimeFromExportRequest(req);
const clientId = req.body?.clientId; const clientId = req.body?.clientId;
if (!clientId) return res.status(400).json({ error: "clientId required" }); if (!clientId) return res.status(400).json({ error: "clientId required" });
@@ -3261,6 +3468,8 @@ function runInstanceScript(args) {
} }
function detectVariantFromHead(head, iface) { function detectVariantFromHead(head, iface) {
if (/^RandomTrailers\s*=/m.test(head)) return "awg31";
if (/^HeaderProtectionKey\s*=/m.test(head)) return "awg3";
if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy"; if (iface === "wg0" || !/^S1\s*=/m.test(head)) return "legacy";
if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2"; if (/^S3\s*=/m.test(head) || /^S4\s*=/m.test(head)) return "awg2";
return "awg"; return "awg";
@@ -3269,6 +3478,8 @@ function detectVariantFromHead(head, iface) {
function inferVariantFromProfile(prof) { function inferVariantFromProfile(prof) {
const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase(); const raw = `${prof.variant || ""} ${prof.id || ""} ${prof.container || ""} ${prof.label || ""}`.toLowerCase();
if (raw.includes("legacy") || prof.iface === "wg0") return "legacy"; if (raw.includes("legacy") || prof.iface === "wg0") return "legacy";
if (raw.includes("awg31") || raw.includes("3.1")) return "awg31";
if (raw.includes("awg3") || raw.includes("3.0")) return "awg3";
if (raw.includes("awg2") || raw.includes("2.0")) return "awg2"; if (raw.includes("awg2") || raw.includes("2.0")) return "awg2";
return "awg"; return "awg";
} }
@@ -3318,7 +3529,7 @@ app.get("/api/instances", requireAuth, async (_req, res) => {
res.json({ instances: items, variants: INSTANCE_VARIANTS }); res.json({ instances: items, variants: INSTANCE_VARIANTS });
}); });
app.post("/api/instances/create", requireAuth, requireProTier, async (req, res) => { app.post("/api/instances/create", requireAuth, async (req, res) => {
const variant = String(req.body?.variant || "").trim(); const variant = String(req.body?.variant || "").trim();
const port = Number(req.body?.port); const port = Number(req.body?.port);
if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." }); if (!INSTANCE_VARIANTS[variant]) return res.status(400).json({ error: "Неизвестный вариант протокола." });
@@ -3348,7 +3559,7 @@ app.post("/api/instances/create", requireAuth, requireProTier, async (req, res)
} }
}); });
app.post("/api/instances/delete", requireAuth, requireProTier, async (req, res) => { app.post("/api/instances/delete", requireAuth, async (req, res) => {
const id = String(req.body?.id || "").trim(); const id = String(req.body?.id || "").trim();
const list = loadManagedProfiles(); const list = loadManagedProfiles();
const found = list.find((p) => p.id === id); const found = list.find((p) => p.id === id);
@@ -3369,7 +3580,7 @@ async function resolveProfileContainer(id) {
catch { return prof.container || null; } catch { return prof.container || null; }
} }
app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) => { app.post("/api/instances/stop", requireAuth, async (req, res) => {
const id = String(req.body?.id || "").trim(); const id = String(req.body?.id || "").trim();
const container = await resolveProfileContainer(id); const container = await resolveProfileContainer(id);
if (!container) return res.status(404).json({ error: "Инстанс не найден." }); if (!container) return res.status(404).json({ error: "Инстанс не найден." });
@@ -3377,7 +3588,7 @@ app.post("/api/instances/stop", requireAuth, requireProTier, async (req, res) =>
catch (e) { res.status(500).json({ error: String(e.message || e) }); } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
}); });
app.post("/api/instances/start", requireAuth, requireProTier, async (req, res) => { app.post("/api/instances/start", requireAuth, async (req, res) => {
const id = String(req.body?.id || "").trim(); const id = String(req.body?.id || "").trim();
const container = await resolveProfileContainer(id); const container = await resolveProfileContainer(id);
if (!container) return res.status(404).json({ error: "Инстанс не найден." }); if (!container) return res.status(404).json({ error: "Инстанс не найден." });
@@ -3404,13 +3615,9 @@ app.listen(PORT, "0.0.0.0", () => {
}); });
setInterval(() => { setInterval(() => {
if (!IS_COMMUNITY) { processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
}
}, SCHEDULER_MS); }, SCHEDULER_MS);
setTimeout(() => { setTimeout(() => {
if (!IS_COMMUNITY) { processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
processAllScheduledDisconnects().catch((e) => console.error("scheduleDisconnect:", e));
}
}, 4000); }, 4000);