Compare commits
15 Commits
b99783e40f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| baa3ef5f76 | |||
| 24a6cb276f | |||
| 222953049d | |||
| d771af866c | |||
| 0d72579edd | |||
| b819eb35b0 | |||
| 809b0ca63d | |||
| f04f9dd1cb | |||
| aae920a5c2 | |||
| c9792a5d5d | |||
| 63f3d202b6 | |||
| 8eed687f66 | |||
| 4c4b682256 | |||
| 6c7bd421e3 | |||
| a8bb70a58f |
@@ -31,6 +31,7 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers.
|
||||
- MTProxy (Telegram) (`mtproxy`)
|
||||
- SMB Server (`smb`)
|
||||
- AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn
|
||||
- Cloudflare WARP Proxy (`cf-warp`) — transparent traffic proxying via Cloudflare
|
||||
|
||||
|
||||
## Requirements
|
||||
@@ -205,6 +206,19 @@ Manage VPN protocols via **Settings → Protocols**:
|
||||
- Configure protocol settings (ports, transport, obfuscation)
|
||||
- **AI Assistant**: Use "Ask AI" to generate complex protocol configurations tailored to your needs (requires OpenRouter API key).
|
||||
|
||||
### Cloudflare WARP Proxy
|
||||
|
||||
WARP transparently proxies **all TCP traffic** from VPN clients through the Cloudflare network, hiding the server's real IP address.
|
||||
|
||||
> **⚠️ Install WARP last** — after all other protocols (AWG, X-Ray, AIVPN, etc.). During installation, WARP automatically detects active VPN containers and interfaces and configures routing for each of them.
|
||||
|
||||
**Supported protocols:**
|
||||
- **AWG / AWG2** — routing via container IP + host redsocks
|
||||
- **X-Ray VLESS** — `warp-out` outbound via SOCKS5 in X-Ray config
|
||||
- **AIVPN / WireGuard** — routing via host-level iptables + redsocks
|
||||
|
||||
**Verification:** connect to VPN and open `https://1.1.1.1/cdn-cgi/trace` — the field `warp=on` confirms it's working.
|
||||
|
||||
### Scenario Testing & Logs
|
||||
|
||||
**Scenario Testing**:
|
||||
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
# Amnezia VPN Web Panel
|
||||
|
||||
Веб-панель управления для VPN-серверов Amnezia AWG (WireGuard).
|
||||
|
||||
## Возможности
|
||||
|
||||
- Развертывание VPN-серверов через SSH (пароль или **SSH-ключ**)
|
||||
- **Импорт из существующих VPN-панелей** (wg-easy, 3x-ui)
|
||||
- **Расширенное управление протоколами** (WireGuard, AmneziaWG, OpenVPN, Shadowsocks и др.)
|
||||
- **AI-настройка протоколов** через OpenRouter (опционально)
|
||||
- Управление клиентскими конфигурациями с **датами истечения**
|
||||
- **Лимиты трафика** для клиентов с автоматическим применением
|
||||
- **Резервное копирование и восстановление** серверов
|
||||
- **Тестирование сценариев**: определение и проверка различных сценариев подключения VPN across протоколов
|
||||
- **Расширенное управление логами**: просмотр, поиск и управление системными и контейнерными логами
|
||||
- Мониторинг статистики трафика
|
||||
- Генерация QR-кодов для мобильных приложений
|
||||
- Многоязычный интерфейс (английский, русский, испанский, немецкий, французский, китайский)
|
||||
- REST API с JWT-аутентификацией
|
||||
- Аутентификация пользователей и контроль доступа
|
||||
- **Автоматическая проверка истечения срока действия клиентов и лимитов трафика** через cron
|
||||
|
||||
## Доступные протоколы
|
||||
|
||||
- AmneziaWG Advanced (`amnezia-wg-advanced`)
|
||||
- AmneziaWG 2.0 (`awg2`)
|
||||
- WireGuard Standard (`wireguard-standard`)
|
||||
- OpenVPN (`openvpn`)
|
||||
- Shadowsocks (`shadowsocks`)
|
||||
- XRay VLESS (`xray-vless`)
|
||||
- MTProxy (Telegram) (`mtproxy`)
|
||||
- SMB Server (`smb`)
|
||||
- AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn
|
||||
- Cloudflare WARP Proxy (`cf-warp`) — прозрачное проксирование трафика через Cloudflare
|
||||
|
||||
## Требования
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
git clone https://github.com/infosave2007/amneziavpnphp.git
|
||||
cd amneziavpnphp
|
||||
cp .env.example .env
|
||||
|
||||
# Для Docker Compose V2 (рекомендуется)
|
||||
docker compose up -d
|
||||
docker compose exec web composer install
|
||||
|
||||
# Дождитесь готовности БД (начальные SQL-файлы миграции применяются автоматически через MySQL entrypoint)
|
||||
until [ "$(docker inspect -f '{{.State.Health.Status}}' amnezia-panel-db 2>/dev/null)" = "healthy" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Или для старой Docker Compose V1
|
||||
docker-compose up -d
|
||||
docker-compose exec web composer install
|
||||
|
||||
until [ "$(docker inspect -f '{{.State.Health.Status}}' amnezia-panel-db 2>/dev/null)" = "healthy" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Ручной режим миграции (для существующих установок / обновлений)
|
||||
set -a; source .env; set +a
|
||||
for f in migrations/*.sql; do
|
||||
docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true
|
||||
done
|
||||
|
||||
# Для Docker Compose V1 ручной режим миграции:
|
||||
# for f in migrations/*.sql; do
|
||||
# docker-compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true
|
||||
# done
|
||||
```
|
||||
|
||||
Доступ: http://localhost:8082
|
||||
|
||||
Данные для входа по умолчанию: admin@amnez.ia / admin123
|
||||
|
||||
### Предварительные требования для удаленного сервера
|
||||
|
||||
Для развертывания протоколов на чистом удаленном хосте, Docker Engine должен быть доступен на этом хосте.
|
||||
Если Docker отсутствует, установите его сначала (пример для Ubuntu):
|
||||
|
||||
```bash
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable --now docker
|
||||
```
|
||||
|
||||
## Настройка
|
||||
|
||||
Отредактируйте `.env`:
|
||||
|
||||
```
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=amnezia_panel
|
||||
DB_USERNAME=amnezia
|
||||
DB_PASSWORD=amnezia
|
||||
|
||||
ADMIN_EMAIL=admin@amnez.ia
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
JWT_SECRET=your-secret-key-change-this
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Добавление VPN-сервера
|
||||
|
||||
1. Серверы → Добавить сервер
|
||||
2. Введите: имя, IP хоста, SSH-порт, имя пользователя
|
||||
3. Выберите метод аутентификации: **Пароль** или **SSH-ключ**
|
||||
- Для SSH-ключа: вставьте ваш приватный ключ (формат PEM/OpenSSH)
|
||||
3. **(Опционально) Включите импорт из существующей панели:**
|
||||
- Отметьте "Импортировать из существующей панели"
|
||||
- Выберите тип панели (wg-easy или 3x-ui)
|
||||
- Загрузите файл резервной копии (JSON)
|
||||
4. Нажмите "Создать сервер"
|
||||
5. Дождитесь развертывания
|
||||
6. Клиенты будут импортированы автоматически, если импорт был включен
|
||||
|
||||
### Создание клиента
|
||||
|
||||
1. Откройте детали сервера
|
||||
2. Введите имя клиента
|
||||
3. **Выберите период истечения** (опционально, по умолчанию: бессрочно)
|
||||
4. **Выберите лимит трафика** (опционально, по умолчанию: безлимитно)
|
||||
5. Нажмите "Создать клиента"
|
||||
6. Скачайте конфигурацию или отсканируйте QR-код
|
||||
|
||||
### Управление истечением срока действия клиента
|
||||
|
||||
Установите истечение через UI или API:
|
||||
```bash
|
||||
# Установить конкретную дату
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-expiration \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"expires_at": "2025-12-31 23:59:59"}'
|
||||
|
||||
# Продлить на 30 дней
|
||||
curl -X POST http://localhost:8082/api/clients/123/extend \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"days": 30}'
|
||||
|
||||
# Получить клиентов, у которых скоро истекает срок (в течение 7 дней)
|
||||
curl http://localhost:8082/api/clients/expiring?days=7 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Управление лимитами трафика
|
||||
|
||||
Установите и отслеживайте лимиты трафика через UI или API:
|
||||
```bash
|
||||
# Установить лимит трафика (10 ГБ = 10737418240 байт)
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"limit_bytes": 10737418240}'
|
||||
|
||||
# Удалить лимит трафика (установить безлимитный)
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"limit_bytes": null}'
|
||||
|
||||
# Проверить статус лимита трафика
|
||||
curl http://localhost:8082/api/clients/123/traffic-limit-status \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Получить клиентов, превысивших лимит трафика
|
||||
curl http://localhost:8082/api/clients/overlimit \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Резервное копирование серверов
|
||||
|
||||
Создавайте и восстанавливайте резервные копии через UI или API:
|
||||
```bash
|
||||
# Создать резервную копию
|
||||
curl -X POST http://localhost:8082/api/servers/1/backup \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Список резервных копий
|
||||
curl http://localhost:8082/api/servers/1/backups \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Восстановить из резервной копии
|
||||
curl -X POST http://localhost:8082/api/servers/1/restore \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"backup_id": 123}'
|
||||
```
|
||||
|
||||
### Управление протоколами
|
||||
|
||||
Управляйте VPN-протоколами через **Настройки → Протоколы**:
|
||||
- Установка/удаление протоколов (WireGuard, AmneziaWG, OpenVPN и др.)
|
||||
- Настройка параметров протокола (порты, транспорт, маскировка)
|
||||
- **AI-ассистент**: используйте "Спросить AI" для генерации сложных конфигураций протоколов, адаптированных к вашим потребностям (требуется API-ключ OpenRouter).
|
||||
|
||||
### Cloudflare WARP Proxy
|
||||
|
||||
WARP прозрачно проксирует **весь TCP-трафик** от VPN-клиентов через сеть Cloudflare, скрывая реальный IP-адрес сервера.
|
||||
|
||||
> **⚠️ Устанавливайте WARP последним** — после всех других протоколов (AWG, X-Ray, AIVPN и др.). Во время установки WARP автоматически обнаруживает активные VPN-контейнеры и интерфейсы и настраивает маршрутизацию для каждого из них.
|
||||
|
||||
**Поддерживаемые протоколы:**
|
||||
- **AWG / AWG2** — маршрутизация через IP контейнера + хост redsocks
|
||||
- **X-Ray VLESS** — исходящий `warp-out` через SOCKS5 в конфигурации X-Ray
|
||||
- **AIVPN / WireGuard** — маршрутизация через iptables + redsocks на уровне хоста
|
||||
|
||||
**Проверка:** подключитесь к VPN и откройте `https://1.1.1.1/cdn-cgi/trace` — поле `warp=on` подтверждает работоспособность.
|
||||
|
||||
### Тестирование сценариев и логи
|
||||
|
||||
**Тестирование сценариев**:
|
||||
- Создавайте тестовые сценарии для проверки подключения через различные протоколы и сетевые условия.
|
||||
- Запускайте автоматические тесты для обеспечения надежности вашей VPN-инфраструктуры.
|
||||
|
||||
**Управление логами**:
|
||||
- Централизованный просмотр всех системных, контейнерных и прикладных логов.
|
||||
- Возможности поиска и фильтрации для быстрой диагностики проблем.
|
||||
|
||||
### AI-ассистент
|
||||
|
||||
Настройте API-ключ OpenRouter in **Настройки** для включения:
|
||||
- Автоматический перевод интерфейса
|
||||
- AI-помощник для настройки протоколов
|
||||
- Интеллектуальные предложения по устранению неполадок
|
||||
|
||||
### Автоматический мониторинг и сбор метрик
|
||||
|
||||
**Сборщик метрик запускается автоматически** при старте контейнера и отслеживается cron каждые 3 минуты. Если процесс падает, он автоматически перезапускается.
|
||||
|
||||
Проверить логи сборщика метрик:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/metrics_collector.log
|
||||
```
|
||||
|
||||
Проверить логи скрипта мониторинга:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/metrics_monitor.log
|
||||
```
|
||||
|
||||
Перезапустить сборщик метрик вручную:
|
||||
```bash
|
||||
docker compose exec web pkill -f collect_metrics.php
|
||||
# Он будет автоматически перезапущен в течение 3 минут скриптом мониторинга
|
||||
```
|
||||
|
||||
### Автоматическая проверка истечения срока действия клиентов
|
||||
|
||||
**Запускается автоматически в Docker-контейнере** каждый час для отключения истекших клиентов.
|
||||
|
||||
Проверить логи cron:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/cron.log
|
||||
```
|
||||
|
||||
Запустить вручную:
|
||||
```bash
|
||||
docker compose exec web php /var/www/html/bin/check_expired_clients.php
|
||||
```
|
||||
|
||||
### Автоматическая проверка лимитов трафика
|
||||
|
||||
**Запускается автоматически в Docker-контейнере** каждый час для отключения клиентов, превысивших лимит трафика.
|
||||
|
||||
Проверить логи cron:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/cron.log
|
||||
```
|
||||
|
||||
Запустить вручную:
|
||||
```bash
|
||||
docker compose exec web php /var/www/html/bin/check_traffic_limits.php
|
||||
```
|
||||
|
||||
### API-аутентификация
|
||||
|
||||
Получить JWT-токен:
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/auth/token \
|
||||
-d "email=admin@amnez.ia&password=admin123"
|
||||
```
|
||||
|
||||
Использовать токен:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8082/api/servers
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Аутентификация
|
||||
```
|
||||
POST /api/auth/token - Получить JWT-токен
|
||||
POST /api/tokens - Создать постоянный API-токен
|
||||
GET /api/tokens - Список API-токенов
|
||||
DELETE /api/tokens/{id} - Отозвать токен
|
||||
```
|
||||
|
||||
### Серверы
|
||||
```
|
||||
GET /api/servers - Список всех серверов
|
||||
POST /api/servers/create - Создать новый сервер
|
||||
Параметры: name, host, port, username, password
|
||||
DELETE /api/servers/{id}/delete - Удалить сервер по ID
|
||||
GET /api/servers/{id}/clients - Список клиентов на сервере
|
||||
```
|
||||
|
||||
### Протоколы
|
||||
```
|
||||
GET /api/protocols/active - Список всех доступных протоколов (JWT-дружественный, включает ID протоколов)
|
||||
GET /api/protocols - Управление протоколами (требует session admin auth, не JWT)
|
||||
GET /api/servers/{id}/protocols - Список установленных протоколов на сервере
|
||||
POST /api/servers/{id}/protocols/install - Установить протокол
|
||||
```
|
||||
|
||||
### Клиенты
|
||||
```
|
||||
GET /api/clients - Список всех клиентов
|
||||
GET /api/clients/{id}/details - Получить детали клиента со статистикой, конфигурацией и QR-кодом
|
||||
GET /api/clients/{id}/qr - Получить QR-код клиента
|
||||
POST /api/clients/create - Создать нового клиента (возвращает конфигурацию и QR-код)
|
||||
Параметры: server_id, name, protocol_id (опционально, по умолчанию: установлен), expires_in_days (опционально)
|
||||
POST /api/clients/{id}/revoke - Отозвать доступ клиента
|
||||
POST /api/clients/{id}/restore - Восстановить доступ клиента
|
||||
DELETE /api/clients/{id}/delete - Удалить клиента по ID (удаляет из БД и сервера)
|
||||
POST /api/clients/{id}/set-expiration - Установить дату истечения клиента
|
||||
Параметры: expires_at (Y-m-d H:i:s или null)
|
||||
POST /api/clients/{id}/extend - Продлить истечение клиента
|
||||
Параметры: days (int)
|
||||
GET /api/clients/expiring - Получить клиентов, у которых скоро истекает срок
|
||||
Параметры: days (по умолчанию: 7)
|
||||
POST /api/clients/{id}/set-traffic-limit - Установить лимит трафика клиента
|
||||
Параметры: limit_bytes (int или null для безлимитного)
|
||||
GET /api/clients/{id}/traffic-limit-status - Получить статус лимита трафика
|
||||
GET /api/clients/overlimit - Получить клиентов, превысивших лимит трафика
|
||||
```
|
||||
|
||||
### Резервные копии
|
||||
```
|
||||
POST /api/servers/{id}/backup - Создать резервную копию сервера
|
||||
GET /api/servers/{id}/backups - Список резервных копий сервера
|
||||
POST /api/servers/{id}/restore - Восстановить из резервной копии
|
||||
Параметры: backup_id
|
||||
DELETE /api/backups/{id} - Удалить резервную копию
|
||||
```
|
||||
|
||||
### Импорт панели
|
||||
```
|
||||
POST /api/servers/{id}/import - Импортировать клиентов из существующей панели
|
||||
Параметры: panel_type (wg-easy|3x-ui), backup_file (multipart/form-data)
|
||||
GET /api/servers/{id}/imports - Получить историю импорта для сервера
|
||||
```
|
||||
|
||||
## Перевод
|
||||
|
||||
Добавьте API-ключ OpenRouter в настройках, затем запустите:
|
||||
```bash
|
||||
docker compose exec web php bin/translate_all.php
|
||||
```
|
||||
|
||||
Или переведите через веб-интерфейс: Настройки → Автоперевод
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
public/index.php - Маршруты
|
||||
inc/ - Основные классы
|
||||
Auth.php - Аутентификация
|
||||
DB.php - Подключение к базе данных
|
||||
Router.php - Маршрутизация URL
|
||||
View.php - Twig-шаблоны
|
||||
VpnServer.php - Управление серверами
|
||||
VpnClient.php - Управление клиентами
|
||||
Translator.php - Многоязычность
|
||||
JWT.php - Токен-аутентификация
|
||||
QrUtil.php - Генерация QR-кодов
|
||||
PanelImporter.php - Импорт из wg-easy/3x-ui
|
||||
InstallProtocolManager.php - Ядро управления протоколами
|
||||
OpenRouterService.php - AI-интеграция
|
||||
templates/ - Twig-шаблоны
|
||||
migrations/ - SQL-миграции (выполняются в алфавитном порядке)
|
||||
```
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- PHP 8.2
|
||||
- MySQL 8.0
|
||||
- Twig 3
|
||||
- Tailwind CSS
|
||||
- Docker
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
Если вы находите этот проект полезным, вы можете поддержать его разработку через пожертвование через Tribute: https://t.me/tribute/app?startapp=dzX1
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
# Amnezia VPN Web Panel
|
||||
|
||||
用于管理 Amnezia AWG (WireGuard) VPN 服务器的 Web 面板。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 通过 SSH 部署 VPN 服务器(密码或 **SSH 密钥**)
|
||||
- **从现有 VPN 面板导入**(wg-easy、3x-ui)
|
||||
- **高级协议管理**(WireGuard、AmneziaWG、OpenVPN、Shadowsocks 等)
|
||||
- **AI 驱动的协议配置** 使用 OpenRouter(可选)
|
||||
- 客户端配置管理,支持**过期日期**
|
||||
- 客户端**流量限制**,自动执行
|
||||
- **服务器备份和恢复**功能
|
||||
- **场景测试**:定义和测试不同协议的网络连接场景
|
||||
- **高级日志管理**:查看、搜索和管理系统和容器日志
|
||||
- 流量统计监控
|
||||
- 为移动应用生成二维码
|
||||
- 多语言界面(英语、俄语、西班牙语、德语、法语、中文)
|
||||
- 带 JWT 认证的 REST API
|
||||
- 用户认证和访问控制
|
||||
- **自动客户端过期和流量限制检查** 通过 cron
|
||||
|
||||
## 可用协议
|
||||
|
||||
- AmneziaWG Advanced (`amnezia-wg-advanced`)
|
||||
- AmneziaWG 2.0 (`awg2`)
|
||||
- WireGuard 标准 (`wireguard-standard`)
|
||||
- OpenVPN (`openvpn`)
|
||||
- Shadowsocks (`shadowsocks`)
|
||||
- XRay VLESS (`xray-vless`)
|
||||
- MTProxy (Telegram) (`mtproxy`)
|
||||
- SMB 服务器 (`smb`)
|
||||
- AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn
|
||||
- Cloudflare WARP 代理 (`cf-warp`) — 通过 Cloudflare 透明代理流量
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/infosave2007/amneziavpnphp.git
|
||||
cd amneziavpnphp
|
||||
cp .env.example .env
|
||||
|
||||
# 对于 Docker Compose V2(推荐)
|
||||
docker compose up -d
|
||||
docker compose exec web composer install
|
||||
|
||||
# 等待数据库准备就绪(初始 SQL 迁移文件由 MySQL 入口点自动应用)
|
||||
until [ "$(docker inspect -f '{{.State.Health.Status}}' amnezia-panel-db 2>/dev/null)" = "healthy" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 或者对于旧版 Docker Compose V1
|
||||
docker-compose up -d
|
||||
docker-compose exec web composer install
|
||||
|
||||
until [ "$(docker inspect -f '{{.State.Health.Status}}' amnezia-panel-db 2>/dev/null)" = "healthy" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 手动迁移模式(仅用于现有安装/更新)
|
||||
set -a; source .env; set +a
|
||||
for f in migrations/*.sql; do
|
||||
docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true
|
||||
done
|
||||
|
||||
# 对于 Docker Compose V1 手动迁移模式:
|
||||
# for f in migrations/*.sql; do
|
||||
# docker-compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < "$f" || true
|
||||
# done
|
||||
```
|
||||
|
||||
访问地址:http://localhost:8082
|
||||
|
||||
默认登录凭据:admin@amnez.ia / admin123
|
||||
|
||||
### 远程服务器前提条件
|
||||
|
||||
要在干净的远程主机上部署协议,该主机必须可用 Docker Engine。
|
||||
如果缺少 Docker,请先安装(Ubuntu 示例):
|
||||
|
||||
```bash
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable --now docker
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=amnezia_panel
|
||||
DB_USERNAME=amnezia
|
||||
DB_PASSWORD=amnezia
|
||||
|
||||
ADMIN_EMAIL=admin@amnez.ia
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
JWT_SECRET=your-secret-key-change-this
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 添加 VPN 服务器
|
||||
|
||||
1. 服务器 → 添加服务器
|
||||
2. 输入:名称、主机 IP、SSH 端口、用户名
|
||||
3. 选择认证方法:**密码** 或 **SSH 密钥**
|
||||
- 对于 SSH 密钥:粘贴您的私钥(PEM/OpenSSH 格式)
|
||||
3. **(可选)启用从现有面板导入:**
|
||||
- 勾选"从现有面板导入"
|
||||
- 选择面板类型(wg-easy 或 3x-ui)
|
||||
- 上传备份文件(JSON)
|
||||
4. 点击"创建服务器"
|
||||
5. 等待部署完成
|
||||
6. 如果启用了导入,客户端将自动导入
|
||||
|
||||
### 创建客户端
|
||||
|
||||
1. 打开服务器详情
|
||||
2. 输入客户端名称
|
||||
3. **选择过期时间**(可选,默认:永不过期)
|
||||
4. **选择流量限制**(可选,默认:无限制)
|
||||
5. 点击创建客户端
|
||||
6. 下载配置或扫描二维码
|
||||
|
||||
### 管理客户端过期时间
|
||||
|
||||
通过 UI 或 API 设置过期时间:
|
||||
```bash
|
||||
# 设置特定日期
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-expiration \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"expires_at": "2025-12-31 23:59:59"}'
|
||||
|
||||
# 延长 30 天
|
||||
curl -X POST http://localhost:8082/api/clients/123/extend \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"days": 30}'
|
||||
|
||||
# 获取即将过期的客户端(7 天内)
|
||||
curl http://localhost:8082/api/clients/expiring?days=7 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### 管理流量限制
|
||||
|
||||
通过 UI 或 API 设置和监控流量限制:
|
||||
```bash
|
||||
# 设置流量限制(10 GB = 10737418240 字节)
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"limit_bytes": 10737418240}'
|
||||
|
||||
# 移除流量限制(设置为无限制)
|
||||
curl -X POST http://localhost:8082/api/clients/123/set-traffic-limit \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"limit_bytes": null}'
|
||||
|
||||
# 检查流量限制状态
|
||||
curl http://localhost:8082/api/clients/123/traffic-limit-status \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 获取超过流量限制的客户端
|
||||
curl http://localhost:8082/api/clients/overlimit \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### 服务器备份
|
||||
|
||||
通过 UI 或 API 创建和恢复备份:
|
||||
```bash
|
||||
# 创建备份
|
||||
curl -X POST http://localhost:8082/api/servers/1/backup \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 列出备份
|
||||
curl http://localhost:8082/api/servers/1/backups \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 从备份恢复
|
||||
curl -X POST http://localhost:8082/api/servers/1/restore \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"backup_id": 123}'
|
||||
```
|
||||
|
||||
### 协议管理
|
||||
|
||||
通过 **设置 → 协议** 管理 VPN 协议:
|
||||
- 安装/卸载协议(WireGuard、AmneziaWG、OpenVPN 等)
|
||||
- 配置协议设置(端口、传输、混淆)
|
||||
- **AI 助手**:使用"询问 AI"生成符合您需求的复杂协议配置(需要 OpenRouter API 密钥)。
|
||||
|
||||
### Cloudflare WARP 代理
|
||||
|
||||
WARP 透明地代理 **所有 TCP 流量** 从 VPN 客户端通过 Cloudflare 网络,隐藏服务器的真实 IP 地址。
|
||||
|
||||
> **⚠️ 最后安装 WARP** — 在所有其他协议之后(AWG、X-Ray、AIVPN 等)。安装过程中,WARP 会自动检测活跃的 VPN 容器和接口,并为每个配置路由。
|
||||
|
||||
**支持的协议:**
|
||||
- **AWG / AWG2** — 通过容器 IP + 主机 redsocks 路由
|
||||
- **X-Ray VLESS** — 通过 X-Ray 配置中的 SOCKS5 `warp-out` 出站
|
||||
- **AIVPN / WireGuard** — 通过主机级 iptables + redsocks 路由
|
||||
|
||||
**验证:** 连接到 VPN 并打开 `https://1.1.1.1/cdn-cgi/trace` — 字段 `warp=on` 确认工作正常。
|
||||
|
||||
### 场景测试和日志
|
||||
|
||||
**场景测试**:
|
||||
- 创建测试场景以验证跨不同协议和网络条件的连接。
|
||||
- 运行自动化测试以确保您的 VPN 基础设施可靠。
|
||||
|
||||
**日志管理**:
|
||||
- 所有系统、容器和应用程序日志的集中视图。
|
||||
- 搜索和过滤功能,快速诊断问题。
|
||||
|
||||
### AI 助手
|
||||
|
||||
在 **设置** 中配置 OpenRouter API 密钥以启用:
|
||||
- 界面自动翻译
|
||||
- AI 辅助协议配置
|
||||
- 智能故障排除建议
|
||||
|
||||
### 自动监控和指标收集
|
||||
|
||||
**指标收集器在容器启动时自动运行**,并由 cron 每 3 分钟监控一次。如果进程崩溃,将自动重启。
|
||||
|
||||
检查指标收集器日志:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/metrics_collector.log
|
||||
```
|
||||
|
||||
检查监控脚本日志:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/metrics_monitor.log
|
||||
```
|
||||
|
||||
手动重启指标收集器:
|
||||
```bash
|
||||
docker compose exec web pkill -f collect_metrics.php
|
||||
# 它将在 3 分钟内由监控脚本自动重启
|
||||
```
|
||||
|
||||
### 自动客户端过期检查
|
||||
|
||||
**在 Docker 容器中自动运行**,每小时禁用过期客户端。
|
||||
|
||||
检查 cron 日志:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/cron.log
|
||||
```
|
||||
|
||||
手动运行:
|
||||
```bash
|
||||
docker compose exec web php /var/www/html/bin/check_expired_clients.php
|
||||
```
|
||||
|
||||
### 自动流量限制检查
|
||||
|
||||
**在 Docker 容器中自动运行**,每小时禁用超过流量限制的客户端。
|
||||
|
||||
检查 cron 日志:
|
||||
```bash
|
||||
docker compose exec web tail -f /var/log/cron.log
|
||||
```
|
||||
|
||||
手动运行:
|
||||
```bash
|
||||
docker compose exec web php /var/www/html/bin/check_traffic_limits.php
|
||||
```
|
||||
|
||||
### API 认证
|
||||
|
||||
获取 JWT 令牌:
|
||||
```bash
|
||||
curl -X POST http://localhost:8082/api/auth/token \
|
||||
-d "email=admin@amnez.ia&password=admin123"
|
||||
```
|
||||
|
||||
使用令牌:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://localhost:8082/api/servers
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### 认证
|
||||
```
|
||||
POST /api/auth/token - 获取 JWT 令牌
|
||||
POST /api/tokens - 创建持久 API 令牌
|
||||
GET /api/tokens - 列出 API 令牌
|
||||
DELETE /api/tokens/{id} - 撤销令牌
|
||||
```
|
||||
|
||||
### 服务器
|
||||
```
|
||||
GET /api/servers - 列出所有服务器
|
||||
POST /api/servers/create - 创建新服务器
|
||||
参数:name, host, port, username, password
|
||||
DELETE /api/servers/{id}/delete - 按 ID 删除服务器
|
||||
GET /api/servers/{id}/clients - 列出服务器上的客户端
|
||||
```
|
||||
|
||||
### 协议
|
||||
```
|
||||
GET /api/protocols/active - 列出所有可用协议(JWT 友好,包含协议 ID)
|
||||
GET /api/protocols - 协议管理端点(需要会话管理员认证,非 JWT)
|
||||
GET /api/servers/{id}/protocols - 列出服务器上已安装的协议
|
||||
POST /api/servers/{id}/protocols/install - 安装协议
|
||||
```
|
||||
|
||||
### 客户端
|
||||
```
|
||||
GET /api/clients - 列出所有客户端
|
||||
GET /api/clients/{id}/details - 获取客户端详情,包括统计信息、配置和二维码
|
||||
GET /api/clients/{id}/qr - 获取客户端二维码
|
||||
POST /api/clients/create - 创建新客户端(返回配置和二维码)
|
||||
参数:server_id, name, protocol_id(可选,默认:已安装), expires_in_days(可选)
|
||||
POST /api/clients/{id}/revoke - 撤销客户端访问
|
||||
POST /api/clients/{id}/restore - 恢复客户端访问
|
||||
DELETE /api/clients/{id}/delete - 按 ID 删除客户端(从数据库和服务器删除)
|
||||
POST /api/clients/{id}/set-expiration - 设置客户端过期日期
|
||||
参数:expires_at(Y-m-d H:i:s 或 null)
|
||||
POST /api/clients/{id}/extend - 延长客户端过期时间
|
||||
参数:days(int)
|
||||
GET /api/clients/expiring - 获取即将过期的客户端
|
||||
参数:days(默认:7)
|
||||
POST /api/clients/{id}/set-traffic-limit - 设置客户端流量限制
|
||||
参数:limit_bytes(int 或 null 表示无限制)
|
||||
GET /api/clients/{id}/traffic-limit-status - 获取流量限制状态
|
||||
GET /api/clients/overlimit - 获取超过流量限制的客户端
|
||||
```
|
||||
|
||||
### 备份
|
||||
```
|
||||
POST /api/servers/{id}/backup - 创建服务器备份
|
||||
GET /api/servers/{id}/backups - 列出服务器备份
|
||||
POST /api/servers/{id}/restore - 从备份恢复
|
||||
参数:backup_id
|
||||
DELETE /api/backups/{id} - 删除备份
|
||||
```
|
||||
|
||||
### 面板导入
|
||||
```
|
||||
POST /api/servers/{id}/import - 从现有面板导入客户端
|
||||
参数:panel_type(wg-easy|3x-ui), backup_file(multipart/form-data)
|
||||
GET /api/servers/{id}/imports - 获取服务器导入历史记录
|
||||
```
|
||||
|
||||
## 翻译
|
||||
|
||||
在设置中添加 OpenRouter API 密钥,然后运行:
|
||||
```bash
|
||||
docker compose exec web php bin/translate_all.php
|
||||
```
|
||||
|
||||
或通过 Web 界面翻译:设置 → 自动翻译
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
public/index.php - 路由
|
||||
inc/ - 核心类
|
||||
Auth.php - 认证
|
||||
DB.php - 数据库连接
|
||||
Router.php - URL 路由
|
||||
View.php - Twig 模板
|
||||
VpnServer.php - 服务器管理
|
||||
VpnClient.php - 客户端管理
|
||||
Translator.php - 多语言
|
||||
JWT.php - 令牌认证
|
||||
QrUtil.php - 二维码生成
|
||||
PanelImporter.php - 从 wg-easy/3x-ui 导入
|
||||
InstallProtocolManager.php - 协议管理核心
|
||||
OpenRouterService.php - AI 集成
|
||||
templates/ - Twig 模板
|
||||
migrations/ - SQL 迁移(按字母顺序执行)
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- PHP 8.2
|
||||
- MySQL 8.0
|
||||
- Twig 3
|
||||
- Tailwind CSS
|
||||
- Docker
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
## 支持项目
|
||||
|
||||
如果您觉得这个项目有用,可以通过 Tribute 捐款支持其开发:https://t.me/tribute/app?startapp=dzX1
|
||||
@@ -0,0 +1,224 @@
|
||||
# Release Notes - Amnezia VPN Web Panel v2.0.1
|
||||
|
||||
**Release Date:** 2026-04-25
|
||||
|
||||
**Previous Release:** v2.0.0 (3 weeks ago)
|
||||
|
||||
## 🆕 What's New in v2.0.1
|
||||
|
||||
This patch release focuses on improving international accessibility with comprehensive documentation in Russian and Chinese, making the panel more accessible to users worldwide.
|
||||
|
||||
### 📄 New Documentation
|
||||
- **Russian Documentation** ([`README_RU.md`](README_RU.md)) - Complete translation with all features, API examples, and troubleshooting guides
|
||||
- **Chinese Documentation** ([`README_ZH.md`](README_ZH.md)) - Full Chinese translation for better accessibility
|
||||
|
||||
## 🎉 Major Features (from v2.0.0)
|
||||
|
||||
### 🌍 Multi-Language Documentation
|
||||
- Added comprehensive Russian documentation ([`README_RU.md`](README_RU.md))
|
||||
- Added comprehensive Chinese documentation ([`README_ZH.md`](README_ZH.md))
|
||||
- Improves accessibility for Russian and Chinese speaking users
|
||||
|
||||
### ☁️ Cloudflare WARP Integration
|
||||
- New protocol: **Cloudflare WARP Proxy** (`cf-warp`)
|
||||
- Transparent TCP traffic proxying through Cloudflare network
|
||||
- Hides server's real IP address from VPN clients
|
||||
- Automatic detection and routing for multiple VPN protocols:
|
||||
- AWG / AWG2 (container IP + host redsocks)
|
||||
- X-Ray VLESS (SOCKS5 `warp-out` outbound)
|
||||
- AIVPN / WireGuard (host-level iptables + redsocks)
|
||||
- Verification via `https://1.1.1.1/cdn-cgi/trace`
|
||||
|
||||
### 🤖 AI-Powered Features
|
||||
- **AI Assistant** for protocol configuration using OpenRouter
|
||||
- Auto-translation of interface via AI
|
||||
- Intelligent troubleshooting suggestions
|
||||
- Context-aware protocol configuration generation
|
||||
|
||||
### 📊 Enhanced Monitoring & Automation
|
||||
- Automatic metrics collection with self-healing (3-minute monitoring)
|
||||
- Automated client expiration checks (hourly)
|
||||
- Automated traffic limit enforcement (hourly)
|
||||
- Centralized log management with search and filtering
|
||||
- Real-time server monitoring and health checks
|
||||
|
||||
### 🔧 Advanced Protocol Management
|
||||
- **AmneziaWG 2.0** (`awg2`) protocol support
|
||||
- **AIVPN** protocol integration
|
||||
- **MTProxy** (Telegram) protocol support
|
||||
- Dynamic protocol installation/uninstallation
|
||||
- Per-protocol configuration management
|
||||
- Protocol-specific port and transport settings
|
||||
|
||||
### 📥 Panel Import Feature
|
||||
- Import from **wg-easy** backup files
|
||||
- Import from **3x-ui** backup files
|
||||
- Automatic client migration
|
||||
- Import history tracking
|
||||
|
||||
### 🔐 Enhanced Security & Access Control
|
||||
- JWT-based API authentication
|
||||
- Persistent API tokens
|
||||
- User roles and permissions
|
||||
- LDAP integration for enterprise environments
|
||||
- SSH key authentication for server deployment
|
||||
|
||||
### 📱 Client Management Enhancements
|
||||
- Client expiration dates with automatic enforcement
|
||||
- Traffic limits with automatic blocking
|
||||
- QR code generation for mobile apps
|
||||
- Client connection statistics and monitoring
|
||||
- Current speed monitoring per client
|
||||
|
||||
### 🧪 Scenario Testing
|
||||
- Define custom VPN connection scenarios
|
||||
- Automated testing across different protocols
|
||||
- Network condition simulation
|
||||
- Reliability verification
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- Fixed AWG2 empty peer parameters (migration 063)
|
||||
- Fixed AIVPN prebuilt binary handling (migration 065)
|
||||
- Fixed AWG2 original parameters completion (migration 064)
|
||||
- Fixed XRay port mapping and IP enforcement
|
||||
- Fixed traffic limit counter offsets for AIVPN
|
||||
- Fixed client connection instructions translation
|
||||
- Fixed WARP heredoc compatibility issues
|
||||
- Fixed WARP subnet detection for AIVPN compatibility
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
### Database Migrations
|
||||
- Added 22 new migration files (048-069) covering:
|
||||
- Protocol management tables
|
||||
- Monitoring and metrics
|
||||
- LDAP configurations
|
||||
- QR code templates
|
||||
- Protocol editor translations
|
||||
- AWG2 and AIVPN support
|
||||
- Cloudflare WARP integration
|
||||
|
||||
### Performance Optimizations
|
||||
- Optimized metrics collection queries
|
||||
- Improved database indexing for client lookups
|
||||
- Enhanced Docker container health checks
|
||||
- Reduced API response times
|
||||
|
||||
### Code Quality
|
||||
- Improved error handling and logging
|
||||
- Better input validation
|
||||
- Enhanced security checks
|
||||
- Cleaner separation of concerns
|
||||
|
||||
## 📚 Documentation Updates
|
||||
|
||||
- Complete Russian translation of all features
|
||||
- Complete Chinese translation of all features
|
||||
- Updated API examples with new endpoints
|
||||
- Added troubleshooting guides
|
||||
- Enhanced installation instructions
|
||||
- Added Cloudflare WARP configuration examples
|
||||
|
||||
## 🔗 API Changes
|
||||
|
||||
### New Endpoints
|
||||
- `POST /api/clients/{id}/set-traffic-limit` - Set client traffic limit
|
||||
- `GET /api/clients/{id}/traffic-limit-status` - Get traffic limit status
|
||||
- `GET /api/clients/overlimit` - Get clients over traffic limit
|
||||
- `POST /api/clients/{id}/set-expiration` - Set client expiration date
|
||||
- `POST /api/clients/{id}/extend` - Extend client expiration
|
||||
- `GET /api/clients/expiring` - Get clients expiring soon
|
||||
- `POST /api/servers/{id}/import` - Import from existing panel
|
||||
- `GET /api/servers/{id}/imports` - Get import history
|
||||
- `POST /api/servers/{id}/backup` - Create server backup
|
||||
- `GET /api/servers/{id}/backups` - List server backups
|
||||
- `POST /api/servers/{id}/restore` - Restore from backup
|
||||
- `GET /api/protocols/active` - List available protocols (JWT-friendly)
|
||||
|
||||
### Enhanced Endpoints
|
||||
- Improved client creation with expiration and traffic limits
|
||||
- Enhanced server management with import capabilities
|
||||
- Better protocol management with AI assistance
|
||||
|
||||
## 🚀 Upgrade Guide
|
||||
|
||||
### From Previous Versions
|
||||
|
||||
1. **Backup your database** before upgrading
|
||||
2. Pull the latest code:
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
3. Run new migrations:
|
||||
```bash
|
||||
docker compose exec -T db mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < migrations/048_enable_xray_stats.sql
|
||||
# ... run all new migration files in order
|
||||
```
|
||||
4. Restart containers:
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
5. Clear application cache if needed
|
||||
|
||||
### Fresh Installation
|
||||
|
||||
See the installation instructions in [`README.md`](README.md), [`README_RU.md`](README_RU.md), or [`README_ZH.md`](README_ZH.md)
|
||||
|
||||
## 📋 Migration Files (Since v2.0.0)
|
||||
|
||||
This release includes all migrations from v2.0.0 plus the following new files:
|
||||
|
||||
### New in v2.0.1:
|
||||
- No new database migrations - this is a documentation-focused release
|
||||
|
||||
### From v2.0.0:
|
||||
- `048_enable_xray_stats.sql` - Enable XRay statistics collection
|
||||
- `049_add_dns_servers.sql` - Add DNS server configuration
|
||||
- `050_fix_awg_random_params.sql` - Fix AWG random parameters
|
||||
- `051_fix_awg_fresh_install.sql` - Fix AWG fresh installation
|
||||
- `052_add_current_speed_to_clients.sql` - Add current speed monitoring
|
||||
- `053_split_speed.sql` - Split upload/download speed
|
||||
- `054_xray_single_ip_enforcement.sql` - XRay IP enforcement
|
||||
- `055_dashboard_online_now_translation.sql` - Dashboard translation
|
||||
- `056_enable_show_text_content_for_xray.sql` - XRay text content
|
||||
- `057_add_protocol_management_translations.sql` - Protocol management
|
||||
- `058_add_awg2_protocol.sql` - AWG2 protocol support
|
||||
- `059_add_mtproxy_protocol.sql` - MTProxy protocol support
|
||||
- `060_add_aivpn_protocol.sql` - AIVPN protocol support
|
||||
- `061_fix_client_connection_instructions_translation.sql` - Translation fix
|
||||
- `062_add_aivpn_counter_offsets.sql` - AIVPN counter fixes
|
||||
- `063_fix_awg2_empty_peer_in_install_script.sql` - AWG2 peer fix
|
||||
- `064_complete_awg2_original_params.sql` - AWG2 parameters
|
||||
- `065_fix_aivpn_prebuilt_binary.sql` - AIVPN binary fix
|
||||
- `066_add_cloudflare_warp_protocol.sql` - WARP protocol
|
||||
- `067_warp_auto_redsocks_integration.sql` - WARP redsocks
|
||||
- `068_fix_warp_heredoc_compat.sql` - WARP heredoc fix
|
||||
- `069_warp_aivpn_subnet_detect.sql` - WARP subnet detection
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Special thanks to:
|
||||
- All contributors who helped with translations
|
||||
- The Amnezia VPN community for feedback and testing
|
||||
- OpenRouter for AI integration support
|
||||
- Cloudflare for WARP technology
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **GitHub Issues:** https://github.com/infosave2007/amneziavpnphp/issues
|
||||
- **Documentation:** See README files in English, Russian, and Chinese
|
||||
- **Donations:** https://t.me/tribute/app?startapp=dzX1
|
||||
|
||||
## 📄 License
|
||||
|
||||
This release is licensed under the MIT License - see the [`LICENSE`](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog:** https://github.com/infosave2007/amneziavpnphp/compare/v2.0.0...v2.0.1
|
||||
|
||||
**Changes since v2.0.0:**
|
||||
- Added comprehensive Russian documentation ([`README_RU.md`](README_RU.md))
|
||||
- Added comprehensive Chinese documentation ([`README_ZH.md`](README_ZH.md))
|
||||
- Updated release notes with multi-language support information
|
||||
+14
-2
@@ -25,15 +25,27 @@ ini_set('display_errors', 1);
|
||||
ini_set('log_errors', 1);
|
||||
ini_set('error_log', '/var/log/metrics_collector_errors.log');
|
||||
|
||||
// Prevent multiple instances using flock (#42)
|
||||
$lockFile = '/var/run/collect_metrics.lock';
|
||||
$lockFp = fopen($lockFile, 'w');
|
||||
if (!$lockFp || !flock($lockFp, LOCK_EX | LOCK_NB)) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Another collector instance is already running. Exiting.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Write PID file for monitoring
|
||||
$pidFile = '/var/run/collect_metrics.pid';
|
||||
file_put_contents($pidFile, getmypid());
|
||||
|
||||
// Register shutdown function to clean up PID file
|
||||
register_shutdown_function(function() use ($pidFile) {
|
||||
// Register shutdown function to clean up PID and lock files
|
||||
register_shutdown_function(function() use ($pidFile, $lockFp, $lockFile) {
|
||||
if (file_exists($pidFile)) {
|
||||
unlink($pidFile);
|
||||
}
|
||||
if ($lockFp) {
|
||||
flock($lockFp, LOCK_UN);
|
||||
fclose($lockFp);
|
||||
}
|
||||
});
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Metrics collector started (PID: " . getmypid() . ")\n";
|
||||
|
||||
Regular → Executable
+15
@@ -2,15 +2,24 @@
|
||||
|
||||
# Monitor and restart metrics collector if it's not running
|
||||
# This script checks if collect_metrics.php is running and restarts it if needed
|
||||
# Uses flock to prevent multiple instances (#42)
|
||||
|
||||
SCRIPT_PATH="/var/www/html/bin/collect_metrics.php"
|
||||
LOG_FILE="/var/log/metrics_monitor.log"
|
||||
PID_FILE="/var/run/collect_metrics.pid"
|
||||
LOCK_FILE="/var/run/collect_metrics.lock"
|
||||
|
||||
log_message() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Use flock to prevent multiple monitor instances
|
||||
exec 200>"$LOCK_FILE"
|
||||
if ! flock -n 200; then
|
||||
log_message "Another monitor instance is running, exiting"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the process is running
|
||||
is_running() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
@@ -22,6 +31,12 @@ is_running() {
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Also check if any collect_metrics.php is running (catches orphan processes)
|
||||
if pgrep -f "collect_metrics.php" > /dev/null 2>&1; then
|
||||
# Update PID file with actual PID
|
||||
pgrep -f "collect_metrics.php" | head -1 > "$PID_FILE"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"php": ">=8.0",
|
||||
"twig/twig": "^3.8",
|
||||
"endroid/qr-code": "^4.8 || ^5.0",
|
||||
"firebase/php-jwt": "^6.11",
|
||||
"firebase/php-jwt": "^7.0",
|
||||
"ext-pdo": "*",
|
||||
"ext-json": "*",
|
||||
"ext-curl": "*",
|
||||
|
||||
+674
-83
@@ -289,25 +289,19 @@ class InstallProtocolManager
|
||||
|
||||
private static function detect(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
$engine = self::getEngine($protocol);
|
||||
if ($engine === 'builtin_awg') {
|
||||
$handler = self::resolveHandler($protocol);
|
||||
|
||||
switch ($handler) {
|
||||
case 'awg':
|
||||
return self::detectBuiltinAwg($server, $protocol);
|
||||
}
|
||||
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
|
||||
// For AWG shell-based scenarios (amnezia-wg, amnezia-wg-advanced), use builtin AWG detection
|
||||
if (self::isAwgProtocol($slug, $protocol)) {
|
||||
return self::detectBuiltinAwg($server, $protocol);
|
||||
}
|
||||
|
||||
// For X-Ray VLESS, use builtin detection
|
||||
if ($slug === 'xray-vless') {
|
||||
case 'xray':
|
||||
return self::detectBuiltinXray($server, $protocol);
|
||||
}
|
||||
|
||||
case 'warp':
|
||||
return self::detectBuiltinWarp($server, $protocol);
|
||||
default:
|
||||
return self::runScript($server, $protocol, 'detect', $options);
|
||||
}
|
||||
}
|
||||
|
||||
public static function install(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
@@ -397,23 +391,14 @@ class InstallProtocolManager
|
||||
|
||||
private static function restore(VpnServer $server, array $protocol, array $detection, array $options = []): array
|
||||
{
|
||||
$engine = self::getEngine($protocol);
|
||||
if ($engine === 'builtin_awg') {
|
||||
$handler = self::resolveHandler($protocol);
|
||||
|
||||
switch ($handler) {
|
||||
case 'awg':
|
||||
return self::restoreBuiltinAwg($server, $protocol, $detection, $options);
|
||||
}
|
||||
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
|
||||
// For AWG shell-based scenarios, use builtin AWG restore
|
||||
if (self::isAwgProtocol($slug, $protocol)) {
|
||||
return self::restoreBuiltinAwg($server, $protocol, $detection, $options);
|
||||
}
|
||||
|
||||
// For X-Ray VLESS, use builtin restore
|
||||
if ($slug === 'xray-vless') {
|
||||
case 'xray':
|
||||
return self::restoreBuiltinXray($server, $protocol, $detection, $options);
|
||||
}
|
||||
|
||||
default:
|
||||
$result = self::runScript($server, $protocol, 'restore', array_merge($options, [
|
||||
'detection' => $detection
|
||||
]));
|
||||
@@ -422,6 +407,7 @@ class InstallProtocolManager
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
private static function detectBuiltinAwg(VpnServer $server, array $protocol): array
|
||||
{
|
||||
@@ -1245,29 +1231,77 @@ class InstallProtocolManager
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function getEngine(array $protocol): string
|
||||
/**
|
||||
* ──────────────────────────────────────────────────────────────────
|
||||
* PROTOCOL HANDLER REGISTRY
|
||||
* ──────────────────────────────────────────────────────────────────
|
||||
* Central dispatcher that determines which builtin handler manages
|
||||
* a given protocol. Every dispatch point (detect, install, uninstall)
|
||||
* MUST use this method instead of ad-hoc slug/regex checks.
|
||||
*
|
||||
* Returns one of:
|
||||
* 'awg' – AmneziaWG / AWG variants (Docker container based)
|
||||
* 'warp' – Cloudflare WARP (systemd service, host-level)
|
||||
* 'xray' – X-Ray VLESS (Docker container based)
|
||||
* 'script' – Generic script-driven protocol (install/uninstall via shell)
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Explicit slug match (highest priority, cannot be overridden)
|
||||
* 2. Engine field from protocol definition
|
||||
* 3. Heuristic: install_script content analysis (lowest priority)
|
||||
*/
|
||||
private static function resolveHandler(array $protocol): string
|
||||
{
|
||||
$definition = $protocol['definition'] ?? [];
|
||||
if (!empty($protocol['install_script'])) {
|
||||
return 'shell';
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
|
||||
// ── 1. Explicit slug → handler mapping (always wins) ──
|
||||
static $slugMap = [
|
||||
// WARP
|
||||
'cf-warp' => 'warp',
|
||||
'cloudflare-warp' => 'warp',
|
||||
// X-Ray
|
||||
'xray-vless' => 'xray',
|
||||
// AWG variants
|
||||
'amnezia-wg' => 'awg',
|
||||
'amnezia-wg-advanced' => 'awg',
|
||||
'awg2' => 'awg',
|
||||
];
|
||||
|
||||
if (isset($slugMap[$slug])) {
|
||||
return $slugMap[$slug];
|
||||
}
|
||||
return $definition['engine'] ?? 'builtin_awg';
|
||||
|
||||
// ── 2. Engine from definition ──
|
||||
$definition = $protocol['definition'] ?? [];
|
||||
$engine = $definition['engine'] ?? '';
|
||||
if ($engine === 'builtin_awg') {
|
||||
return 'awg';
|
||||
}
|
||||
|
||||
// ── 3. Heuristic: AWG Docker image in install_script ──
|
||||
// Only check if no explicit slug/engine match above
|
||||
if (empty($protocol['install_script'])) {
|
||||
// No install_script and no engine → default to AWG (legacy behavior)
|
||||
return 'awg';
|
||||
}
|
||||
|
||||
$installScript = (string) $protocol['install_script'];
|
||||
if (preg_match('/amneziavpn\/amnezia-wg|docker\s.*amnezia-awg/i', $installScript)) {
|
||||
return 'awg';
|
||||
}
|
||||
|
||||
// ── 4. Fallback: generic script protocol ──
|
||||
return 'script';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a protocol is an AWG variant (by slug or install_script content)
|
||||
* Used to route shell-based AWG scenarios to builtin AWG detection/restore
|
||||
* Legacy compatibility: get engine string
|
||||
*/
|
||||
private static function isAwgProtocol(string $slug, array $protocol): bool
|
||||
private static function getEngine(array $protocol): string
|
||||
{
|
||||
if (in_array($slug, ['amnezia-wg', 'amnezia-wg-advanced', 'awg2'], true)) {
|
||||
return true;
|
||||
}
|
||||
$installScript = (string) ($protocol['install_script'] ?? '');
|
||||
if ($installScript !== '' && preg_match('/amneziavpn\/amnezia-wg|amnezia\/awg|amnezia-awg/i', $installScript)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
$handler = self::resolveHandler($protocol);
|
||||
if ($handler === 'awg') return 'builtin_awg';
|
||||
return 'shell';
|
||||
}
|
||||
|
||||
private static function fallbackProtocols(): array
|
||||
@@ -1338,25 +1372,19 @@ class InstallProtocolManager
|
||||
*/
|
||||
public static function runDetection(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
$engine = self::getEngine($protocol);
|
||||
if ($engine === 'builtin_awg') {
|
||||
$handler = self::resolveHandler($protocol);
|
||||
|
||||
switch ($handler) {
|
||||
case 'awg':
|
||||
return self::detectBuiltinAwg($server, $protocol);
|
||||
}
|
||||
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
|
||||
// For AWG shell-based scenarios (amnezia-wg, amnezia-wg-advanced), use builtin AWG detection
|
||||
if (self::isAwgProtocol($slug, $protocol)) {
|
||||
return self::detectBuiltinAwg($server, $protocol);
|
||||
}
|
||||
|
||||
// For X-Ray VLESS, use builtin detection
|
||||
if ($slug === 'xray-vless') {
|
||||
case 'xray':
|
||||
return self::detectBuiltinXray($server, $protocol);
|
||||
}
|
||||
|
||||
case 'warp':
|
||||
return self::detectBuiltinWarp($server, $protocol);
|
||||
default:
|
||||
return self::runScript($server, $protocol, 'detect', $options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a protocol from the given server. Supports builtin AWG and scripted protocols
|
||||
@@ -1364,16 +1392,16 @@ class InstallProtocolManager
|
||||
*/
|
||||
public static function uninstall(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
$engine = self::getEngine($protocol);
|
||||
if ($engine === 'builtin_awg') {
|
||||
return self::uninstallBuiltinAwg($server, $protocol, $options);
|
||||
}
|
||||
$slug = $protocol['slug'] ?? 'unknown';
|
||||
$handler = self::resolveHandler($protocol);
|
||||
Logger::appendInstall($server->getId(), 'UNINSTALL: slug=' . $slug . ' handler=' . $handler);
|
||||
|
||||
// For script-driven protocols, try to detect AWG scenario and fallback to builtin uninstall
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
if (self::isAwgProtocol($slug, $protocol)) {
|
||||
// Prefer builtin AWG uninstall by default because script variants may have CRLF issues
|
||||
// or leave behind the canonical container name, causing install conflicts.
|
||||
switch ($handler) {
|
||||
case 'warp':
|
||||
return self::uninstallBuiltinWarp($server, $protocol, $options);
|
||||
|
||||
case 'awg':
|
||||
// Prefer builtin AWG uninstall; script variant only on explicit request
|
||||
if (!empty($options['use_script_uninstall'])) {
|
||||
$hasScript = isset($protocol['uninstall_script']) && trim((string) $protocol['uninstall_script']) !== '';
|
||||
if ($hasScript) {
|
||||
@@ -1381,11 +1409,13 @@ class InstallProtocolManager
|
||||
}
|
||||
}
|
||||
return self::uninstallBuiltinAwg($server, $protocol, $options);
|
||||
}
|
||||
|
||||
// For other script-driven protocols, look for an "uninstall" phase in scripts
|
||||
case 'xray':
|
||||
case 'script':
|
||||
default:
|
||||
return self::runScript($server, $protocol, 'uninstall', $options);
|
||||
}
|
||||
}
|
||||
|
||||
private static function uninstallBuiltinAwg(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
@@ -1450,8 +1480,9 @@ class InstallProtocolManager
|
||||
|
||||
// ── Check for existing installation before doing anything destructive ──
|
||||
$slug = $protocol['slug'] ?? '';
|
||||
$isAwg = $engine === 'builtin_awg' || self::isAwgProtocol($slug, $protocol);
|
||||
$isXray = $slug === 'xray-vless';
|
||||
$handler = self::resolveHandler($protocol);
|
||||
$isAwg = $handler === 'awg';
|
||||
$isXray = $handler === 'xray';
|
||||
|
||||
if ($isAwg) {
|
||||
$detection = self::detectBuiltinAwg($server, $protocol);
|
||||
@@ -1491,6 +1522,17 @@ class InstallProtocolManager
|
||||
}
|
||||
}
|
||||
|
||||
// For Cloudflare WARP — always run install script even if WARP binary exists
|
||||
// because the script is idempotent and handles redsocks/iptables setup
|
||||
if (self::resolveHandler($protocol) === 'warp') {
|
||||
$warpDetection = self::detectBuiltinWarp($server, $protocol);
|
||||
Logger::appendInstall($serverId, 'WARP detect result: status=' . ($warpDetection['status'] ?? 'null'));
|
||||
if (($warpDetection['status'] ?? '') === 'existing') {
|
||||
Logger::appendInstall($serverId, 'Existing WARP found, running install script anyway for redsocks/iptables setup');
|
||||
// Don't return — fall through to run the install script
|
||||
}
|
||||
}
|
||||
|
||||
// ── No existing installation found — proceed with fresh install ──
|
||||
|
||||
if ($engine === 'builtin_awg') {
|
||||
@@ -1517,6 +1559,10 @@ class InstallProtocolManager
|
||||
}
|
||||
}
|
||||
|
||||
$existingProtocol = $server->getData()['install_protocol'] ?? '';
|
||||
$currentSlug = $protocol['slug'] ?? '';
|
||||
$isFirstProtocol = ($existingProtocol === '' || $existingProtocol === $currentSlug);
|
||||
if ($isFirstProtocol) {
|
||||
self::markServerActive($serverId, null, [
|
||||
'vpn_port' => $resolvedPort,
|
||||
'server_public_key' => $res['server_public_key'] ?? null,
|
||||
@@ -1524,6 +1570,10 @@ class InstallProtocolManager
|
||||
'container_name' => $res['container_name'] ?? null,
|
||||
'awg_params' => $resolvedAwgParams,
|
||||
]);
|
||||
} else {
|
||||
// Secondary protocol — just mark active, don't overwrite primary data
|
||||
self::markServerActive($serverId, null, []);
|
||||
}
|
||||
|
||||
$pdo = DB::conn();
|
||||
$pid = self::resolveProtocolId($protocol);
|
||||
@@ -1653,10 +1703,22 @@ class InstallProtocolManager
|
||||
$stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()');
|
||||
$stmt2->execute([$serverId, $pid, json_encode($config)]);
|
||||
}
|
||||
// Save vpn_port to vpn_servers table for shell protocols (like AIVPN)
|
||||
// Save vpn_port to vpn_servers table ONLY for the primary (first) protocol
|
||||
// Secondary protocols store their ports in server_protocols.config_data only
|
||||
if ($port !== null && $port > 0) {
|
||||
$existingProtocol = $server->getData()['install_protocol'] ?? '';
|
||||
$currentSlug = $protocol['slug'] ?? '';
|
||||
$isFirstProtocol = ($existingProtocol === '' || $existingProtocol === $currentSlug);
|
||||
if ($isFirstProtocol) {
|
||||
self::markServerActive($serverId, null, ['vpn_port' => $port]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WARP: Auto-patch X-Ray outbound to route through WARP ──
|
||||
if (self::resolveHandler($protocol) === 'warp') {
|
||||
self::patchXrayForWarp($server);
|
||||
}
|
||||
|
||||
return $res;
|
||||
} catch (Throwable $e) {
|
||||
$message = (string) $e->getMessage();
|
||||
@@ -1723,10 +1785,11 @@ class InstallProtocolManager
|
||||
$binaryCmd = '/usr/local/bin/aivpn-server';
|
||||
|
||||
// Verify the binary exists, fallback to other locations if needed
|
||||
// Use auto-detection for sudo requirement (null = auto-detect for docker commands)
|
||||
$checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
|
||||
escapeshellarg($containerName),
|
||||
escapeshellarg($binaryCmd));
|
||||
$checkResult = (string) $server->executeCommand($checkCmd, true);
|
||||
$checkResult = (string) $server->executeCommand($checkCmd, null);
|
||||
if (strpos($checkResult, 'found') === false) {
|
||||
// Try alternative locations
|
||||
$fallbacks = [
|
||||
@@ -1739,7 +1802,7 @@ class InstallProtocolManager
|
||||
$checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
|
||||
escapeshellarg($containerName),
|
||||
escapeshellarg($loc));
|
||||
$checkResult = (string) $server->executeCommand($checkCmd, true);
|
||||
$checkResult = (string) $server->executeCommand($checkCmd, null);
|
||||
if (strpos($checkResult, 'found') !== false) {
|
||||
$binaryCmd = $loc;
|
||||
break;
|
||||
@@ -1770,10 +1833,10 @@ class InstallProtocolManager
|
||||
Logger::appendInstall($server->getId(), 'Adding AIVPN client via builtin add_client: ' . $clientName . ' in ' . $containerName);
|
||||
|
||||
try {
|
||||
$output = (string) $server->executeCommand($cmd, true);
|
||||
// Use auto-detection for sudo requirement (null = auto-detect for docker commands)
|
||||
$output = (string) $server->executeCommand($cmd, null);
|
||||
} catch (Exception $e) {
|
||||
// Container may be restarting or unavailable - try host binary fallback
|
||||
Logger::appendInstall($server->getId(), 'AIVPN add_client docker exec failed (container may be restarting): ' . $e->getMessage());
|
||||
Logger::appendInstall($server->getId(), 'AIVPN add_client docker exec failed: ' . $e->getMessage());
|
||||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||||
if ($hostResult !== null) {
|
||||
return $hostResult;
|
||||
@@ -1781,14 +1844,12 @@ class InstallProtocolManager
|
||||
return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
|
||||
}
|
||||
|
||||
// Check if docker exec returned an error (container not running, etc.)
|
||||
$trimmedOutput = trim($output);
|
||||
if ($trimmedOutput === '' ||
|
||||
stripos($trimmedOutput, 'Error response from daemon') !== false ||
|
||||
stripos($trimmedOutput, 'is restarting') !== false ||
|
||||
stripos($trimmedOutput, 'No such container') !== false ||
|
||||
stripos($trimmedOutput, 'executable file not found') !== false) {
|
||||
// Container unavailable - try host binary fallback
|
||||
Logger::appendInstall($server->getId(), 'AIVPN add_client container unavailable, trying host binary fallback');
|
||||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||||
if ($hostResult !== null) {
|
||||
@@ -1797,11 +1858,25 @@ class InstallProtocolManager
|
||||
return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
|
||||
}
|
||||
|
||||
if (stripos($trimmedOutput, 'error') !== false || stripos($trimmedOutput, 'failed') !== false) {
|
||||
Logger::appendInstall($server->getId(), 'AIVPN add_client returned error: ' . substr($trimmedOutput, 0, 200));
|
||||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||||
if ($hostResult !== null) {
|
||||
return $hostResult;
|
||||
}
|
||||
return ['success' => false, 'error' => $trimmedOutput];
|
||||
}
|
||||
|
||||
$parsed = self::parseAivpnAddClientOutput($output);
|
||||
|
||||
if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) {
|
||||
$head = substr(str_replace(["\r", "\n"], ' ', $trimmedOutput), 0, 220);
|
||||
throw new Exception('AIVPN add_client succeeded but no connection key found in output: ' . $head);
|
||||
Logger::appendInstall($server->getId(), 'AIVPN add_client no connection key in output: ' . $head);
|
||||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||||
if ($hostResult !== null) {
|
||||
return $hostResult;
|
||||
}
|
||||
return ['success' => false, 'error' => 'No connection key found'];
|
||||
}
|
||||
|
||||
$result = ['success' => true];
|
||||
@@ -2580,4 +2655,520 @@ class InstallProtocolManager
|
||||
|
||||
Logger::appendInstall($serverId, "AWG client import complete: imported {$imported} clients");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Cloudflare WARP — builtin detection, uninstall, status
|
||||
// WARP runs as a systemd service (warp-svc), NOT as a Docker container
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect existing Cloudflare WARP installation on the server
|
||||
*/
|
||||
private static function detectBuiltinWarp(VpnServer $server, array $protocol): array
|
||||
{
|
||||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||||
$proxyPort = $metadata['proxy_port'] ?? 40000;
|
||||
|
||||
// Check if warp-cli binary exists
|
||||
$warpCliCheck = trim($server->executeCommand('command -v warp-cli 2>/dev/null || echo ""', true));
|
||||
if ($warpCliCheck === '') {
|
||||
return [
|
||||
'status' => 'absent',
|
||||
'message' => 'Cloudflare WARP не установлен на сервере'
|
||||
];
|
||||
}
|
||||
|
||||
// Check warp-svc service status
|
||||
$svcStatus = trim($server->executeCommand('systemctl is-active warp-svc 2>/dev/null || echo "inactive"', true));
|
||||
|
||||
// Get WARP connection status
|
||||
$warpStatus = trim($server->executeCommand('warp-cli --accept-tos status 2>/dev/null || echo "error"', true));
|
||||
|
||||
$isConnected = (bool) preg_match('/Connected/i', $warpStatus);
|
||||
$isRegistered = !preg_match('/Registration Missing|unregistered/i', $warpStatus);
|
||||
|
||||
if (!$isRegistered) {
|
||||
return [
|
||||
'status' => 'partial',
|
||||
'message' => 'WARP установлен, но не зарегистрирован',
|
||||
'details' => [
|
||||
'warp_cli' => $warpCliCheck,
|
||||
'service_status' => $svcStatus,
|
||||
'warp_status' => $warpStatus,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Get WARP mode
|
||||
$warpMode = '';
|
||||
if (preg_match('/Mode:\s*(\S+)/i', $warpStatus, $m)) {
|
||||
$warpMode = $m[1];
|
||||
}
|
||||
|
||||
// Get WARP account info
|
||||
$accountInfo = trim($server->executeCommand('warp-cli --accept-tos registration show 2>/dev/null || echo ""', true));
|
||||
$accountId = '';
|
||||
if (preg_match('/Account\s*ID[:\s]+([a-zA-Z0-9-]+)/i', $accountInfo, $m)) {
|
||||
$accountId = $m[1];
|
||||
}
|
||||
|
||||
// Check if proxy port is listening
|
||||
$portListening = trim($server->executeCommand(
|
||||
'ss -tlnp 2>/dev/null | grep ":' . (int) $proxyPort . '" | head -1 || echo ""', true
|
||||
));
|
||||
|
||||
// Get WARP IP (best-effort)
|
||||
$warpIp = '';
|
||||
if ($isConnected && $portListening !== '') {
|
||||
$traceOut = trim($server->executeCommand(
|
||||
'curl -x socks5h://127.0.0.1:' . (int) $proxyPort . ' -s --max-time 5 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo ""', true
|
||||
));
|
||||
if (preg_match('/ip=([^\s]+)/', $traceOut, $m)) {
|
||||
$warpIp = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'existing',
|
||||
'message' => 'Cloudflare WARP установлен и ' . ($isConnected ? 'подключён' : 'отключён'),
|
||||
'details' => [
|
||||
'warp_cli' => $warpCliCheck,
|
||||
'service_status' => $svcStatus,
|
||||
'warp_status_raw' => $warpStatus,
|
||||
'connected' => $isConnected,
|
||||
'registered' => $isRegistered,
|
||||
'warp_mode' => $warpMode,
|
||||
'warp_proxy_port' => (int) $proxyPort,
|
||||
'warp_ip' => $warpIp,
|
||||
'warp_account' => $accountId,
|
||||
'port_listening' => $portListening !== '',
|
||||
'summary' => sprintf(
|
||||
'WARP %s, mode=%s, proxy=%s:%d%s',
|
||||
$isConnected ? 'connected' : 'disconnected',
|
||||
$warpMode ?: 'unknown',
|
||||
'127.0.0.1',
|
||||
(int) $proxyPort,
|
||||
$warpIp !== '' ? ', exit_ip=' . $warpIp : ''
|
||||
)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall Cloudflare WARP from the server (systemd service, not Docker)
|
||||
*/
|
||||
private static function uninstallBuiltinWarp(VpnServer $server, array $protocol, array $options = []): array
|
||||
{
|
||||
$serverId = $server->getId();
|
||||
Logger::appendInstall($serverId, 'Uninstalling Cloudflare WARP (full cleanup)...');
|
||||
|
||||
try {
|
||||
// Run entire uninstall as a single remote script to avoid SSH escaping issues
|
||||
$script = <<<'BASH'
|
||||
#!/bin/bash
|
||||
echo "WARP_UNINSTALL_START"
|
||||
|
||||
# 1. Restore X-Ray config
|
||||
XRAY_NAME=$(docker ps 2>/dev/null | grep -i xray | awk '{ print $NF }' | head -1)
|
||||
if [ -n "$XRAY_NAME" ]; then
|
||||
# Try server.json first (actual runtime config), then config.json
|
||||
XRAY_CFG_PATH=""
|
||||
for P in /opt/amnezia/xray/server.json /etc/xray/config.json; do
|
||||
CONTENT=$(docker exec "$XRAY_NAME" cat "$P" 2>/dev/null || echo "")
|
||||
if [ -n "$CONTENT" ] && echo "$CONTENT" | grep -q "warp-out"; then
|
||||
XRAY_CFG_PATH="$P"
|
||||
XRAY_CFG="$CONTENT"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$XRAY_CFG_PATH" ]; then
|
||||
echo "$XRAY_CFG" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
cfg = json.load(sys.stdin)
|
||||
cfg['outbounds'] = [o for o in cfg.get('outbounds',[]) if o.get('tag') != 'warp-out']
|
||||
if 'routing' in cfg:
|
||||
cfg['routing']['rules'] = [r for r in cfg['routing'].get('rules',[]) if r.get('outboundTag') != 'warp-out']
|
||||
if not cfg['routing']['rules']: del cfg['routing']
|
||||
print(json.dumps(cfg, indent=2))
|
||||
except: pass
|
||||
" 2>/dev/null | docker exec -i "$XRAY_NAME" tee "$XRAY_CFG_PATH" > /dev/null 2>&1
|
||||
docker restart "$XRAY_NAME" 2>/dev/null || true
|
||||
echo "xray_restored"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Remove DNAT rules
|
||||
DOCKER_GW=$(docker network inspect bridge 2>/dev/null | grep Gateway | head -1 | awk -F'"' '{print $4}')
|
||||
if [ -z "$DOCKER_GW" ]; then DOCKER_GW="172.17.0.1"; fi
|
||||
iptables -t nat -D OUTPUT -d "$DOCKER_GW" -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true
|
||||
iptables -t nat -D PREROUTING -d "$DOCKER_GW" -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true
|
||||
iptables -t nat -D PREROUTING -d "$DOCKER_GW" -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true
|
||||
echo "dnat_removed"
|
||||
|
||||
# 3. Remove REDSOCKS_WARP chain
|
||||
SUBNETS=$(cat /var/lib/cloudflare-warp/routed_subnets 2>/dev/null || echo "10.8.1.0/24 10.0.0.0/24")
|
||||
for S in $SUBNETS; do
|
||||
iptables -t nat -D PREROUTING -s "$S" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
done
|
||||
iptables -t nat -F REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -X REDSOCKS_WARP 2>/dev/null || true
|
||||
echo "iptables_cleaned"
|
||||
|
||||
# 4. Remove redsocks
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
systemctl disable redsocks-warp 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/redsocks-warp.service
|
||||
rm -rf /etc/redsocks
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
echo "redsocks_removed"
|
||||
|
||||
# 5. Disconnect and remove WARP
|
||||
warp-cli --accept-tos disconnect 2>/dev/null || true
|
||||
warp-cli --accept-tos registration delete 2>/dev/null || true
|
||||
systemctl stop warp-svc 2>/dev/null || true
|
||||
systemctl disable warp-svc 2>/dev/null || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get remove -y cloudflare-warp >/dev/null 2>&1 || true
|
||||
apt-get autoremove -y >/dev/null 2>&1 || true
|
||||
echo "warp_removed"
|
||||
|
||||
# 6. Cleanup
|
||||
rm -rf /var/lib/cloudflare-warp 2>/dev/null || true
|
||||
rm -f /etc/apt/sources.list.d/cloudflare-client.list 2>/dev/null || true
|
||||
rm -f /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg 2>/dev/null || true
|
||||
rm -f /etc/sysctl.d/99-warp.conf 2>/dev/null || true
|
||||
sysctl -w net.ipv4.conf.docker0.route_localnet=0 2>/dev/null || true
|
||||
sysctl -w net.ipv4.conf.all.route_localnet=0 2>/dev/null || true
|
||||
|
||||
# 7. Save iptables
|
||||
mkdir -p /etc/iptables
|
||||
iptables-save > /etc/iptables/rules.v4 2>/dev/null || true
|
||||
|
||||
echo "WARP_UNINSTALL_DONE"
|
||||
BASH;
|
||||
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: writing script to server...');
|
||||
$b64 = base64_encode($script);
|
||||
// Phase 1: write script file
|
||||
$server->executeCommand("echo " . $b64 . " | base64 -d > /tmp/_warp_uninstall.sh && chmod +x /tmp/_warp_uninstall.sh", true);
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: executing script...');
|
||||
// Phase 2: execute script
|
||||
$output = $server->executeCommand("bash /tmp/_warp_uninstall.sh 2>&1; rm -f /tmp/_warp_uninstall.sh", true);
|
||||
$outputStr = (string) $output;
|
||||
Logger::appendInstall($serverId, 'WARP uninstall output: ' . substr(str_replace(["\r", "\n"], ' ', $outputStr), 0, 500));
|
||||
|
||||
$success = strpos($outputStr, 'WARP_UNINSTALL_DONE') !== false;
|
||||
|
||||
if ($success) {
|
||||
Logger::appendInstall($serverId, 'WARP uninstalled successfully (full cleanup)');
|
||||
} else {
|
||||
Logger::appendInstall($serverId, 'WARP uninstall script may have partially failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'message' => $success ? 'Cloudflare WARP удалён' : 'WARP удалён частично, проверьте логи',
|
||||
'mode' => 'uninstall'
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
Logger::appendInstall($serverId, 'WARP uninstall exception: ' . $e->getMessage());
|
||||
throw new Exception('WARP uninstall failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove WARP outbound and routing rules from X-Ray config
|
||||
* Restores X-Ray to direct (freedom) outbound mode
|
||||
*/
|
||||
private static function unpatchXrayFromWarp(VpnServer $server): void
|
||||
{
|
||||
$serverId = $server->getId();
|
||||
|
||||
try {
|
||||
$xrayContainer = trim($server->executeCommand(
|
||||
'docker ps 2>/dev/null | grep -i xray | awk \'{ print $NF }\' | head -1 || echo ""', true
|
||||
));
|
||||
if ($xrayContainer === '') {
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: no X-Ray container, skipping config restore');
|
||||
return;
|
||||
}
|
||||
|
||||
$containerArg = escapeshellarg($xrayContainer);
|
||||
$configRaw = trim($server->executeCommand(
|
||||
"docker exec -i {$containerArg} cat /etc/xray/config.json 2>/dev/null", true
|
||||
));
|
||||
if ($configRaw === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = json_decode($configRaw, true);
|
||||
if (!is_array($config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove warp-out outbound
|
||||
$outbounds = $config['outbounds'] ?? [];
|
||||
$hadWarp = false;
|
||||
$newOutbounds = [];
|
||||
foreach ($outbounds as $ob) {
|
||||
if (($ob['tag'] ?? '') === 'warp-out') {
|
||||
$hadWarp = true;
|
||||
continue; // skip warp-out
|
||||
}
|
||||
$newOutbounds[] = $ob;
|
||||
}
|
||||
|
||||
if (!$hadWarp) {
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: X-Ray has no warp-out outbound, nothing to restore');
|
||||
return;
|
||||
}
|
||||
|
||||
$config['outbounds'] = $newOutbounds;
|
||||
|
||||
// Remove warp routing rules
|
||||
if (isset($config['routing']['rules']) && is_array($config['routing']['rules'])) {
|
||||
$newRules = [];
|
||||
foreach ($config['routing']['rules'] as $rule) {
|
||||
if (($rule['outboundTag'] ?? '') === 'warp-out') {
|
||||
continue; // skip warp routing rule
|
||||
}
|
||||
$newRules[] = $rule;
|
||||
}
|
||||
$config['routing']['rules'] = $newRules;
|
||||
|
||||
// If routing is empty, remove it entirely for clean config
|
||||
if (empty($config['routing']['rules'])) {
|
||||
unset($config['routing']);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back config
|
||||
$newConfig = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$b64Config = base64_encode($newConfig);
|
||||
$server->executeCommand(
|
||||
"echo {$b64Config} | base64 -d | docker exec -i {$containerArg} tee /etc/xray/config.json > /dev/null", true
|
||||
);
|
||||
|
||||
// Restart X-Ray
|
||||
$server->executeCommand("docker restart {$containerArg} 2>/dev/null || true", true);
|
||||
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: X-Ray config restored (warp-out removed), container restarted');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Logger::appendInstall($serverId, 'WARP uninstall: X-Ray restore failed (non-fatal): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WARP runtime status from a server (used by API endpoint)
|
||||
* Returns connection status, proxy port, exit IP, and account info
|
||||
*/
|
||||
public static function getWarpStatus(VpnServer $server): array
|
||||
{
|
||||
$warpCliCheck = trim($server->executeCommand('command -v warp-cli 2>/dev/null || echo ""', true));
|
||||
if ($warpCliCheck === '') {
|
||||
return [
|
||||
'installed' => false,
|
||||
'connected' => false,
|
||||
'message' => 'WARP не установлен'
|
||||
];
|
||||
}
|
||||
|
||||
$svcStatus = trim($server->executeCommand('systemctl is-active warp-svc 2>/dev/null || echo "inactive"', true));
|
||||
$warpStatus = trim($server->executeCommand('warp-cli --accept-tos status 2>/dev/null || echo "error"', true));
|
||||
$isConnected = (bool) preg_match('/Connected/i', $warpStatus);
|
||||
|
||||
$warpMode = '';
|
||||
if (preg_match('/Mode:\s*(\S+)/i', $warpStatus, $m)) {
|
||||
$warpMode = $m[1];
|
||||
}
|
||||
|
||||
// Get proxy port from settings
|
||||
$proxyPortRaw = trim($server->executeCommand('warp-cli --accept-tos settings 2>/dev/null | grep -i "proxy port" || echo ""', true));
|
||||
$proxyPort = 40000;
|
||||
if (preg_match('/(\d+)/', $proxyPortRaw, $m)) {
|
||||
$proxyPort = (int) $m[1];
|
||||
}
|
||||
|
||||
$warpIp = '';
|
||||
$portListening = false;
|
||||
if ($isConnected) {
|
||||
$portCheck = trim($server->executeCommand(
|
||||
'ss -tlnp 2>/dev/null | grep ":' . $proxyPort . '" | head -1 || echo ""', true
|
||||
));
|
||||
$portListening = $portCheck !== '';
|
||||
|
||||
if ($portListening) {
|
||||
$traceOut = trim($server->executeCommand(
|
||||
'curl -x socks5h://127.0.0.1:' . $proxyPort . ' -s --max-time 5 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo ""', true
|
||||
));
|
||||
if (preg_match('/ip=([^\s]+)/', $traceOut, $m)) {
|
||||
$warpIp = $m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'installed' => true,
|
||||
'connected' => $isConnected,
|
||||
'service_status' => $svcStatus,
|
||||
'mode' => $warpMode,
|
||||
'proxy_port' => $proxyPort,
|
||||
'proxy_listening' => $portListening,
|
||||
'warp_ip' => $warpIp,
|
||||
'warp_status_raw' => $warpStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-patch X-Ray config to route outbound traffic through WARP SOCKS5 proxy
|
||||
* X-Ray runs in Docker bridge mode, so we need:
|
||||
* 1. iptables DNAT: docker_gateway:40000 → 127.0.0.1:40000
|
||||
* 2. X-Ray outbound: socks5 → docker_gateway:40000
|
||||
*/
|
||||
private static function patchXrayForWarp(VpnServer $server): void
|
||||
{
|
||||
$serverId = $server->getId();
|
||||
|
||||
try {
|
||||
// Find X-Ray container
|
||||
$xrayContainer = trim($server->executeCommand(
|
||||
'docker ps 2>/dev/null | grep -i xray | awk \'{ print $NF }\' | head -1 || echo ""', true
|
||||
));
|
||||
if ($xrayContainer === '') {
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: no X-Ray container found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: found container ' . $xrayContainer);
|
||||
|
||||
// Get Docker bridge gateway IP
|
||||
$dockerGw = trim($server->executeCommand(
|
||||
'docker network inspect bridge 2>/dev/null | grep Gateway | head -1 | sed \'s/.*"Gateway"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/\' || echo "172.17.0.1"', true
|
||||
));
|
||||
if ($dockerGw === '') {
|
||||
$dockerGw = '172.17.0.1';
|
||||
}
|
||||
|
||||
// Setup iptables DNAT so Docker containers can reach WARP via gateway IP
|
||||
$server->executeCommand(
|
||||
'iptables -t nat -D OUTPUT -d ' . escapeshellarg($dockerGw) . ' -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true', true
|
||||
);
|
||||
$server->executeCommand(
|
||||
'iptables -t nat -A OUTPUT -d ' . escapeshellarg($dockerGw) . ' -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true', true
|
||||
);
|
||||
// Also allow in PREROUTING for container-originated traffic
|
||||
$server->executeCommand(
|
||||
'iptables -t nat -D PREROUTING -d ' . escapeshellarg($dockerGw) . ' -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true', true
|
||||
);
|
||||
$server->executeCommand(
|
||||
'iptables -t nat -A PREROUTING -d ' . escapeshellarg($dockerGw) . ' -p tcp --dport 40000 -j DNAT --to-destination 127.0.0.1:40000 2>/dev/null || true', true
|
||||
);
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: iptables DNAT ' . $dockerGw . ':40000 → 127.0.0.1:40000');
|
||||
|
||||
// Enable route_localnet so DNAT to 127.0.0.1 works for Docker container traffic
|
||||
$server->executeCommand('sysctl -w net.ipv4.conf.docker0.route_localnet=1 2>/dev/null || true', true);
|
||||
$server->executeCommand('sysctl -w net.ipv4.conf.all.route_localnet=1 2>/dev/null || true', true);
|
||||
$server->executeCommand('grep -q route_localnet /etc/sysctl.d/99-warp.conf 2>/dev/null || { mkdir -p /etc/sysctl.d; echo "net.ipv4.conf.docker0.route_localnet=1" >> /etc/sysctl.d/99-warp.conf; echo "net.ipv4.conf.all.route_localnet=1" >> /etc/sysctl.d/99-warp.conf; }', true);
|
||||
|
||||
// Read X-Ray config — try /opt/amnezia/xray/server.json first (actual runtime config),
|
||||
// fall back to /etc/xray/config.json (Docker volume mount)
|
||||
$containerArg = escapeshellarg($xrayContainer);
|
||||
$xrayConfigPath = '/opt/amnezia/xray/server.json';
|
||||
$configRaw = trim($server->executeCommand(
|
||||
"docker exec -i {$containerArg} cat {$xrayConfigPath} 2>/dev/null", true
|
||||
));
|
||||
if ($configRaw === '' || $configRaw === 'cat: can\'t open') {
|
||||
$xrayConfigPath = '/etc/xray/config.json';
|
||||
$configRaw = trim($server->executeCommand(
|
||||
"docker exec -i {$containerArg} cat {$xrayConfigPath} 2>/dev/null", true
|
||||
));
|
||||
}
|
||||
if ($configRaw === '') {
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: could not read X-Ray config');
|
||||
return;
|
||||
}
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: using config ' . $xrayConfigPath);
|
||||
|
||||
$config = json_decode($configRaw, true);
|
||||
if (!is_array($config)) {
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: config.json is not valid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if warp-out already exists
|
||||
$outbounds = $config['outbounds'] ?? [];
|
||||
foreach ($outbounds as $ob) {
|
||||
if (($ob['tag'] ?? '') === 'warp-out') {
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: warp-out outbound already exists');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag existing freedom outbound as "direct" if not tagged
|
||||
foreach ($outbounds as &$ob) {
|
||||
if (($ob['protocol'] ?? '') === 'freedom' && empty($ob['tag'])) {
|
||||
$ob['tag'] = 'direct';
|
||||
}
|
||||
}
|
||||
unset($ob);
|
||||
|
||||
// Add warp-out SOCKS5 outbound
|
||||
$outbounds[] = [
|
||||
'tag' => 'warp-out',
|
||||
'protocol' => 'socks',
|
||||
'settings' => [
|
||||
'servers' => [
|
||||
[
|
||||
'address' => $dockerGw,
|
||||
'port' => 40000
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$config['outbounds'] = $outbounds;
|
||||
|
||||
// Set default routing: all traffic through warp-out
|
||||
if (!isset($config['routing'])) {
|
||||
$config['routing'] = [];
|
||||
}
|
||||
if (!isset($config['routing']['rules'])) {
|
||||
$config['routing']['rules'] = [];
|
||||
}
|
||||
|
||||
// Add rule: route everything through warp-out (as first rule)
|
||||
$hasWarpRule = false;
|
||||
foreach ($config['routing']['rules'] as $rule) {
|
||||
if (($rule['outboundTag'] ?? '') === 'warp-out') {
|
||||
$hasWarpRule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasWarpRule) {
|
||||
// Add catch-all rule at end to route through WARP
|
||||
$config['routing']['rules'][] = [
|
||||
'type' => 'field',
|
||||
'outboundTag' => 'warp-out',
|
||||
'network' => 'tcp,udp'
|
||||
];
|
||||
}
|
||||
|
||||
// Write back config
|
||||
$newConfig = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$b64Config = base64_encode($newConfig);
|
||||
$server->executeCommand(
|
||||
"echo {$b64Config} | base64 -d | docker exec -i {$containerArg} tee {$xrayConfigPath} > /dev/null", true
|
||||
);
|
||||
|
||||
// Restart X-Ray container
|
||||
$server->executeCommand("docker restart {$containerArg} 2>/dev/null || true", true);
|
||||
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch: outbound added to ' . $xrayConfigPath . ', container restarted');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Logger::appendInstall($serverId, 'WARP X-Ray patch failed (non-fatal): ' . $e->getMessage());
|
||||
// Non-fatal — WARP still works for AWG clients
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+161
-136
@@ -83,17 +83,121 @@ class ServerMonitoring
|
||||
|
||||
/**
|
||||
* Collect all server metrics
|
||||
* Uses a single SSH call to minimize connections (#42)
|
||||
*/
|
||||
public function collectMetrics(): array
|
||||
{
|
||||
// Combine all metric commands into one SSH call
|
||||
// Use semicolons instead of && to ensure all commands execute even if one fails
|
||||
$combinedCmd = implode('; ', [
|
||||
"echo CPU_START",
|
||||
"top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - \$1}'",
|
||||
"echo RAM_START",
|
||||
"free -m | grep Mem | awk '{print \$3, \$2}'",
|
||||
"echo DISK_START",
|
||||
"df -BM / | tail -1 | awk '{print int(\$3/1024), int(\$2/1024)}'",
|
||||
"echo NET_RX_START",
|
||||
"cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/rx_bytes",
|
||||
"echo NET_TX_START",
|
||||
"cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/tx_bytes",
|
||||
]);
|
||||
|
||||
$result1 = $this->execSSH($combinedCmd);
|
||||
|
||||
// Parse first batch
|
||||
$cpu = null;
|
||||
$ramUsed = null;
|
||||
$ramTotal = null;
|
||||
$diskUsed = null;
|
||||
$diskTotal = null;
|
||||
$rxBytes1 = null;
|
||||
$txBytes1 = null;
|
||||
|
||||
if ($result1) {
|
||||
$lines = explode("\n", trim($result1));
|
||||
$section = '';
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === 'CPU_START') { $section = 'cpu'; continue; }
|
||||
if ($line === 'RAM_START') { $section = 'ram'; continue; }
|
||||
if ($line === 'DISK_START') { $section = 'disk'; continue; }
|
||||
if ($line === 'NET_RX_START') { $section = 'rx'; continue; }
|
||||
if ($line === 'NET_TX_START') { $section = 'tx'; continue; }
|
||||
|
||||
switch ($section) {
|
||||
case 'cpu':
|
||||
$cpu = (float) $line;
|
||||
$section = '';
|
||||
break;
|
||||
case 'ram':
|
||||
$parts = preg_split('/\s+/', $line);
|
||||
if (count($parts) >= 2) {
|
||||
$ramUsed = (int) $parts[0];
|
||||
$ramTotal = (int) $parts[1];
|
||||
}
|
||||
$section = '';
|
||||
break;
|
||||
case 'disk':
|
||||
$parts = preg_split('/\s+/', $line);
|
||||
if (count($parts) >= 2) {
|
||||
$diskUsed = (float) $parts[0];
|
||||
$diskTotal = (float) $parts[1];
|
||||
}
|
||||
$section = '';
|
||||
break;
|
||||
case 'rx':
|
||||
$rxBytes1 = (int) $line;
|
||||
$section = '';
|
||||
break;
|
||||
case 'tx':
|
||||
$txBytes1 = (int) $line;
|
||||
$section = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second SSH call after 1 second for network speed (only if first succeeded)
|
||||
$rxMbps = null;
|
||||
$txMbps = null;
|
||||
if ($rxBytes1 !== null && $txBytes1 !== null) {
|
||||
sleep(1);
|
||||
$netCmd = implode('; ', [
|
||||
"echo RX",
|
||||
"cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/rx_bytes",
|
||||
"echo TX",
|
||||
"cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/tx_bytes",
|
||||
]);
|
||||
$result2 = $this->execSSH($netCmd);
|
||||
if ($result2) {
|
||||
$lines = explode("\n", trim($result2));
|
||||
$section = '';
|
||||
$rxBytes2 = null;
|
||||
$txBytes2 = null;
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === 'RX') { $section = 'rx'; continue; }
|
||||
if ($line === 'TX') { $section = 'tx'; continue; }
|
||||
if ($section === 'rx') { $rxBytes2 = (int) $line; $section = ''; }
|
||||
if ($section === 'tx') { $txBytes2 = (int) $line; $section = ''; }
|
||||
}
|
||||
if ($rxBytes2 !== null) {
|
||||
$rxMbps = round((($rxBytes2 - $rxBytes1) * 8) / 1000000, 2);
|
||||
}
|
||||
if ($txBytes2 !== null) {
|
||||
$txMbps = round((($txBytes2 - $txBytes1) * 8) / 1000000, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$metrics = [
|
||||
'cpu_percent' => $this->getCpuUsage(),
|
||||
'ram_used_mb' => $this->getRamUsed(),
|
||||
'ram_total_mb' => $this->getRamTotal(),
|
||||
'disk_used_gb' => $this->getDiskUsed(),
|
||||
'disk_total_gb' => $this->getDiskTotal(),
|
||||
'network_rx_mbps' => $this->getNetworkRxSpeed(),
|
||||
'network_tx_mbps' => $this->getNetworkTxSpeed(),
|
||||
'cpu_percent' => $cpu,
|
||||
'ram_used_mb' => $ramUsed,
|
||||
'ram_total_mb' => $ramTotal,
|
||||
'disk_used_gb' => $diskUsed,
|
||||
'disk_total_gb' => $diskTotal,
|
||||
'network_rx_mbps' => $rxMbps,
|
||||
'network_tx_mbps' => $txMbps,
|
||||
];
|
||||
|
||||
$this->saveServerMetrics($metrics);
|
||||
@@ -116,11 +220,10 @@ class ServerMonitoring
|
||||
}
|
||||
|
||||
// Pre-fetch X-ray stats only for Xray servers.
|
||||
// Otherwise we block AWG/WireGuard stats collection with irrelevant Xray errors.
|
||||
if ($this->isXrayServer()) {
|
||||
if (!$this->fetchXrayStats()) {
|
||||
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
|
||||
return []; // Abort only for Xray servers
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +243,6 @@ class ServerMonitoring
|
||||
|
||||
$stats = $this->getClientStats($client);
|
||||
if ($stats) {
|
||||
// Check if speed values are excessively high (spike detection)
|
||||
// Use 10Gbps (1250 MB/s) as sanity limit. 1250 * 1024 * 1024 ~ 1.3e9
|
||||
// Actually ServerMonitoring calculates bytes/sec.
|
||||
// If speed is > 2 Gbit/s likely an error (unless on 10G link, but rare)
|
||||
// Let's rely on simple positive check for now.
|
||||
|
||||
$this->saveClientMetrics($client['id'], $stats);
|
||||
$results[] = [
|
||||
'client_id' => $client['id'],
|
||||
@@ -159,113 +256,6 @@ class ServerMonitoring
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CPU usage percentage
|
||||
*/
|
||||
private function getCpuUsage(): ?float
|
||||
{
|
||||
$cmd = "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - \$1}'";
|
||||
$result = $this->execSSH($cmd);
|
||||
|
||||
return $result ? (float) trim($result) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RAM used in MB
|
||||
*/
|
||||
private function getRamUsed(): ?int
|
||||
{
|
||||
$cmd = "free -m | grep Mem | awk '{print \$3}'";
|
||||
$result = $this->execSSH($cmd);
|
||||
|
||||
return $result ? (int) trim($result) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total RAM in MB
|
||||
*/
|
||||
private function getRamTotal(): ?int
|
||||
{
|
||||
$cmd = "free -m | grep Mem | awk '{print \$2}'";
|
||||
$result = $this->execSSH($cmd);
|
||||
|
||||
return $result ? (int) trim($result) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk used in GB
|
||||
*/
|
||||
private function getDiskUsed(): ?float
|
||||
{
|
||||
$cmd = "df -BG / | tail -1 | awk '{print \$3}' | sed 's/G//'";
|
||||
$result = $this->execSSH($cmd);
|
||||
|
||||
return $result ? (float) trim($result) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total disk in GB
|
||||
*/
|
||||
private function getDiskTotal(): ?float
|
||||
{
|
||||
$cmd = "df -BG / | tail -1 | awk '{print \$2}' | sed 's/G//'";
|
||||
$result = $this->execSSH($cmd);
|
||||
|
||||
return $result ? (float) trim($result) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network RX speed in Mbps
|
||||
*/
|
||||
private function getNetworkRxSpeed(): ?float
|
||||
{
|
||||
// Get bytes received on main interface
|
||||
$cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/rx_bytes";
|
||||
$bytes1 = $this->execSSH($cmd);
|
||||
|
||||
if (!$bytes1)
|
||||
return null;
|
||||
|
||||
sleep(1); // Wait 1 second
|
||||
|
||||
$bytes2 = $this->execSSH($cmd);
|
||||
|
||||
if (!$bytes2)
|
||||
return null;
|
||||
|
||||
// Calculate speed in Mbps
|
||||
$bytesPerSec = (int) $bytes2 - (int) $bytes1;
|
||||
$mbps = ($bytesPerSec * 8) / 1000000;
|
||||
|
||||
return round($mbps, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network TX speed in Mbps
|
||||
*/
|
||||
private function getNetworkTxSpeed(): ?float
|
||||
{
|
||||
// Get bytes transmitted on main interface
|
||||
$cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/tx_bytes";
|
||||
$bytes1 = $this->execSSH($cmd);
|
||||
|
||||
if (!$bytes1)
|
||||
return null;
|
||||
|
||||
sleep(1); // Wait 1 second
|
||||
|
||||
$bytes2 = $this->execSSH($cmd);
|
||||
|
||||
if (!$bytes2)
|
||||
return null;
|
||||
|
||||
// Calculate speed in Mbps
|
||||
$bytesPerSec = (int) $bytes2 - (int) $bytes1;
|
||||
$mbps = ($bytesPerSec * 8) / 1000000;
|
||||
|
||||
return round($mbps, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client current stats and calculate speed
|
||||
*/
|
||||
@@ -378,18 +368,20 @@ class ServerMonitoring
|
||||
$rawOutNow = (int) ($aivpn['bytes_out'] ?? 0);
|
||||
|
||||
// Detect counter rollover/reset in AIVPN source and preserve cumulative totals.
|
||||
// bytes_in -> received (download), bytes_out -> sent (upload)
|
||||
if ($rawInNow < $rawInPrev) {
|
||||
$offsetIn = max($offsetIn + $rawInPrev, $prevSent);
|
||||
$offsetIn = max($offsetIn + $rawInPrev, $prevReceived);
|
||||
}
|
||||
if ($rawOutNow < $rawOutPrev) {
|
||||
$offsetOut = max($offsetOut + $rawOutPrev, $prevReceived);
|
||||
$offsetOut = max($offsetOut + $rawOutPrev, $prevSent);
|
||||
}
|
||||
|
||||
$candidateSent = $offsetIn + $rawInNow;
|
||||
$candidateReceived = $offsetOut + $rawOutNow;
|
||||
$candidateReceived = $offsetIn + $rawInNow;
|
||||
$candidateSent = $offsetOut + $rawOutNow;
|
||||
|
||||
// AIVPN stores per-client counters as bytes_in/bytes_out.
|
||||
// Map to panel semantics: sent=client upload, received=client download.
|
||||
// AIVPN bytes_in = data downloaded BY client (server→client)
|
||||
// AIVPN bytes_out = data uploaded BY client (client→server)
|
||||
// Verified via `aivpn-server --list-clients` where bytes_in = DOWNLOAD column
|
||||
$bytesSent = max($prevSent, $candidateSent);
|
||||
$bytesReceived = max($prevReceived, $candidateReceived);
|
||||
|
||||
@@ -629,27 +621,60 @@ class ServerMonitoring
|
||||
|
||||
/**
|
||||
* Execute SSH command on server
|
||||
* Supports both password and SSH key authentication
|
||||
*/
|
||||
private function execSSH(string $cmd): ?string
|
||||
{
|
||||
$host = $this->serverData['host'];
|
||||
$port = (int)$this->serverData['port'];
|
||||
$username = $this->serverData['username'];
|
||||
$password = $this->serverData['password'];
|
||||
$sshKey = $this->serverData['ssh_key'] ?? '';
|
||||
$password = $this->serverData['password'] ?? '';
|
||||
|
||||
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
|
||||
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR';
|
||||
$keyFile = '';
|
||||
|
||||
if (!empty($sshKey)) {
|
||||
// SSH key authentication
|
||||
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey');
|
||||
// Normalize key (fix \r\n, ensure trailing newline)
|
||||
$sshKey = str_replace("\r\n", "\n", $sshKey);
|
||||
$sshKey = str_replace("\r", "\n", $sshKey);
|
||||
if ($sshKey !== '' && substr($sshKey, -1) !== "\n") {
|
||||
$sshKey .= "\n";
|
||||
}
|
||||
file_put_contents($keyFile, $sshKey);
|
||||
chmod($keyFile, 0600);
|
||||
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
|
||||
$sshCmd = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
$password,
|
||||
"ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
$port,
|
||||
$sshOptions,
|
||||
$username,
|
||||
$host,
|
||||
escapeshellarg($cmd)
|
||||
);
|
||||
} else {
|
||||
// Password authentication
|
||||
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
|
||||
$sshCmd = sprintf(
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
escapeshellarg($password),
|
||||
$port,
|
||||
$sshOptions,
|
||||
$username,
|
||||
$host,
|
||||
escapeshellarg($cmd)
|
||||
);
|
||||
}
|
||||
|
||||
$output = shell_exec($sshCmd);
|
||||
|
||||
// Clean up temp key file
|
||||
if ($keyFile && file_exists($keyFile)) {
|
||||
unlink($keyFile);
|
||||
}
|
||||
|
||||
return $output ?: null;
|
||||
}
|
||||
|
||||
@@ -1093,8 +1118,8 @@ class ServerMonitoring
|
||||
|
||||
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
|
||||
$sshCmd = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
$password,
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
escapeshellarg($password),
|
||||
$port,
|
||||
$sshOptions,
|
||||
$username,
|
||||
@@ -1175,8 +1200,8 @@ class ServerMonitoring
|
||||
|
||||
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
|
||||
$sshCmd = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
$password,
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>/dev/null",
|
||||
escapeshellarg($password),
|
||||
$port,
|
||||
$sshOptions,
|
||||
$username,
|
||||
|
||||
+133
-53
@@ -199,12 +199,22 @@ class VpnClient
|
||||
|
||||
$defaultAwgParams = self::getAwgParamDefaults($slug);
|
||||
|
||||
// Add AWG parameters (use UPPERCASE keys internal logic)
|
||||
foreach (array_keys($defaultAwgParams) as $key) {
|
||||
if (isset($cleanAwgParams[$key])) {
|
||||
$vars[$key] = $cleanAwgParams[$key];
|
||||
// AmneziaWG requires the client's obfuscation params to EXACTLY match
|
||||
// the server's. When the server's params are known (synced from its
|
||||
// config), mirror them verbatim and leave anything the server does not
|
||||
// use empty (those lines are stripped from the rendered config below).
|
||||
// Only fall back to protocol defaults when NO params were synced at all
|
||||
// (best effort) — injecting AWG 2.0 defaults (H1-H4 ranges, S3/S4, I1)
|
||||
// onto a classic AmneziaWG server silently breaks the handshake.
|
||||
// (issue #50)
|
||||
$awgKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4', 'I1', 'I2', 'I3', 'I4', 'I5'];
|
||||
if (!empty($cleanAwgParams)) {
|
||||
foreach ($awgKeys as $key) {
|
||||
$vars[$key] = array_key_exists($key, $cleanAwgParams) ? $cleanAwgParams[$key] : '';
|
||||
}
|
||||
} else {
|
||||
$vars[$key] = $defaultAwgParams[$key];
|
||||
foreach ($awgKeys as $key) {
|
||||
$vars[$key] = $defaultAwgParams[$key] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +253,18 @@ class VpnClient
|
||||
);
|
||||
}
|
||||
|
||||
// Drop AWG obfuscation lines that ended up empty (params the server
|
||||
// does not use, e.g. S3/S4/I1-I5 on a classic AmneziaWG server). An
|
||||
// empty "S3 =" / "I1 =" line is invalid, and any param the client
|
||||
// carries but the server lacks breaks the handshake. (issue #50)
|
||||
if ($isWireguard) {
|
||||
$config = preg_replace(
|
||||
'/^[ \t]*(?:Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4|I1|I2|I3|I4|I5)[ \t]*=[ \t]*\r?\n/mi',
|
||||
'',
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
self::addClientToServer($serverData, $keys['public'], $clientIP);
|
||||
$qrCode = self::generateQRCode($config, $slug);
|
||||
$priv = $keys['private'];
|
||||
@@ -797,28 +819,36 @@ class VpnClient
|
||||
private static function generateClientKeys(array $serverData, string $clientName): array
|
||||
{
|
||||
$containerName = $serverData['container_name'];
|
||||
$protocolSlug = (string) ($serverData['install_protocol'] ?? '');
|
||||
$isAwg2 = (stripos($containerName, 'awg2') !== false || $protocolSlug === 'awg2');
|
||||
$wgTool = $isAwg2 ? 'awg' : 'wg';
|
||||
|
||||
// Detect the WireGuard userspace tool INSIDE the container instead of
|
||||
// hardcoding it. Different AWG2 images expose it under different names:
|
||||
// the official Amnezia image ships only `wg` (a patched AmneziaWG binary),
|
||||
// while amneziawg-go provides `awg` (with `wg` symlinked to it). Hardcoding
|
||||
// `awg` made `awg genkey` fail with "awg: not found" on the Amnezia image,
|
||||
// which is the actual cause of the "Failed to generate client keys" error
|
||||
// in issue #50. Prefer `awg`, fall back to `wg`.
|
||||
$script = 'set -e; umask 077; '
|
||||
. 'tool=$(command -v awg 2>/dev/null || command -v wg 2>/dev/null); '
|
||||
. '[ -n "$tool" ] || { echo no_wg_tool; exit 1; }; '
|
||||
. 'priv=$("$tool" genkey | tr -d "\r\n"); [ -n "$priv" ] || { echo empty_private_key; exit 1; }; '
|
||||
. 'pub=$(printf "%s\n" "$priv" | "$tool" pubkey | tr -d "\r\n"); [ -n "$pub" ] || { echo empty_public_key; exit 1; }; '
|
||||
. 'printf "%s\n---\n%s\n" "$priv" "$pub"';
|
||||
|
||||
$cmd = sprintf(
|
||||
"docker exec -i %s sh -lc 'set -e; umask 077; priv=\$(%s genkey | tr -d " . '"' . "\\r\\n" . '"' . "); [ -n \"\$priv\" ] || { echo empty_private_key; exit 1; }; pub=\$(printf " . '"' . "%%s\\n" . '"' . " \"\$priv\" | %s pubkey | tr -d " . '"' . "\\r\\n" . '"' . "); [ -n \"\$pub\" ] || { echo empty_public_key; exit 1; }; printf " . '"' . "%%s\\n---\\n%%s\\n" . '"' . " \"\$priv\" \"\$pub\"'",
|
||||
'docker exec -i %s sh -lc %s',
|
||||
escapeshellarg($containerName),
|
||||
$wgTool,
|
||||
$wgTool
|
||||
escapeshellarg($script)
|
||||
);
|
||||
|
||||
$escaped = escapeshellarg($cmd);
|
||||
$sshCmd = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1",
|
||||
$serverData['password'],
|
||||
$serverData['port'],
|
||||
$serverData['username'],
|
||||
$serverData['host'],
|
||||
$escaped
|
||||
);
|
||||
// Route the command through VpnServer::executeCommand so that SSH key
|
||||
// authentication and automatic docker sudo detection are handled the same
|
||||
// way as every other remote operation. The previous implementation built
|
||||
// its own password-only SSH command (PubkeyAuthentication=no, no sudo),
|
||||
// which failed on key-based servers and on hosts where docker needs sudo,
|
||||
// producing the "Failed to generate client keys" error (issue #50).
|
||||
$server = new VpnServer((int) $serverData['id']);
|
||||
$out = (string) $server->executeCommand($cmd); // null sudo => auto-detect for docker
|
||||
|
||||
$out = (string) shell_exec($sshCmd);
|
||||
$parts = explode("---", trim($out));
|
||||
|
||||
if (count($parts) < 2) {
|
||||
@@ -860,8 +890,11 @@ class VpnClient
|
||||
try {
|
||||
$containerName = $serverData['container_name'] ?? 'amnezia-awg';
|
||||
$server = new VpnServer($serverData['id']);
|
||||
// AWG2 stores its config as awg0.conf (inside the container the path is
|
||||
// always /opt/amnezia/awg/). Read awg0.conf first, then fall back to the
|
||||
// legacy wg0.conf so externally created peers are still detected.
|
||||
$cmd = sprintf(
|
||||
"docker exec %s cat /opt/amnezia/awg/wg0.conf 2>/dev/null",
|
||||
"docker exec %s sh -c 'cat /opt/amnezia/awg/awg0.conf 2>/dev/null; cat /opt/amnezia/awg/wg0.conf 2>/dev/null'",
|
||||
escapeshellarg($containerName)
|
||||
);
|
||||
$serverConfig = $server->executeCommand($cmd, true);
|
||||
@@ -1053,37 +1086,32 @@ class VpnClient
|
||||
$dns = '1.1.1.1, 1.0.0.1';
|
||||
}
|
||||
|
||||
// Extract AWG parameters.
|
||||
// NOTE: amnezia-awg does not expose these via `wg show` in many builds,
|
||||
// so we primarily read them from /opt/amnezia/awg/wg0.conf.
|
||||
// Extract AWG obfuscation parameters. The server config file is the
|
||||
// source of truth: it holds the EXACT params awg-quick applied, in the
|
||||
// server's own format (single-value H1-H4 for classic AmneziaWG, or
|
||||
// "a-b" ranges for AWG 2.0) and only the params the server actually
|
||||
// uses. Client configs must mirror these exactly or the AmneziaWG
|
||||
// handshake silently fails (issue #50). Inside the container the
|
||||
// config always lives under /opt/amnezia/awg/.
|
||||
$awgParams = [];
|
||||
foreach (['/opt/amnezia/awg/awg0.conf', '/opt/amnezia/awg/wg0.conf', '/etc/wireguard/wg0.conf'] as $confPath) {
|
||||
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $confPath);
|
||||
if (!empty($awgParams)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy attempt: some builds print jc/jmin/... in `wg show` output.
|
||||
// Fallback: some builds only expose params via `wg show` (no readable
|
||||
// config file). Accept both single integers and "a-b" ranges so
|
||||
// classic AmneziaWG H1-H4 values are not dropped.
|
||||
if (empty($awgParams)) {
|
||||
$wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null";
|
||||
$wgOutput = (string) $server->executeCommand($wgShowCmd, true);
|
||||
$paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4', 'i1', 'i2', 'i3', 'i4', 'i5'];
|
||||
foreach ($paramNames as $param) {
|
||||
// For H1-H4 parameters, expect format like "1443912531-1981073285" (two values with dash)
|
||||
// For other parameters, expect single integer value
|
||||
if (in_array($param, ['h1', 'h2', 'h3', 'h4'], true)) {
|
||||
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+-\d+)/mi', $wgOutput, $matches)) {
|
||||
$awgParams[strtoupper($param)] = $matches[1];
|
||||
foreach (['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4', 'i1', 'i2', 'i3', 'i4', 'i5'] as $param) {
|
||||
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+(?:-\d+)?)/mi', $wgOutput, $matches)) {
|
||||
$val = $matches[1];
|
||||
$awgParams[strtoupper($param)] = ctype_digit($val) ? (int) $val : $val;
|
||||
}
|
||||
} else {
|
||||
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) {
|
||||
$awgParams[strtoupper($param)] = (int) $matches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Primary source: wg0.conf
|
||||
if (empty($awgParams)) {
|
||||
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $primaryConfigDir . '/wg0.conf');
|
||||
if (empty($awgParams) && $primaryConfigDir !== '/opt/amnezia/awg') {
|
||||
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf');
|
||||
}
|
||||
if (empty($awgParams)) {
|
||||
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,6 +1182,10 @@ class VpnClient
|
||||
$config .= "Address = {$clientIP}/32\n";
|
||||
$config .= "DNS = 1.1.1.1, 1.0.0.1\n";
|
||||
$config .= "PrivateKey = {$privateKey}\n";
|
||||
// AmneziaWG obfuscation adds per-packet overhead; without a reduced MTU
|
||||
// the tunnel connects but large packets are dropped (no usable traffic).
|
||||
// 1280 matches the official Amnezia app default. (issue #50)
|
||||
$config .= "MTU = 1280\n";
|
||||
|
||||
// Add AWG parameters (in the order used by Amnezia app)
|
||||
// For awg2 include I1-I5, S3, S4; for regular awg only H1-H4, Jc, Jmin, Jmax, S1, S2
|
||||
@@ -1213,9 +1245,12 @@ class VpnClient
|
||||
throw new Exception('Refusing to add client with empty public key');
|
||||
}
|
||||
|
||||
// Determine correct tool names (awg for AWG2, wg for standard)
|
||||
$wgTool = $isAwg2 ? 'awg' : 'wg';
|
||||
$wgQuickTool = $isAwg2 ? 'awg-quick' : 'wg-quick';
|
||||
// Determine correct tool names by probing the container. The official
|
||||
// Amnezia image exposes only `wg`/`wg-quick`; amneziawg-go provides
|
||||
// `awg`/`awg-quick`. Hardcoding `awg` broke peer setup on the Amnezia
|
||||
// image (issue #50). Prefer `awg`, fall back to `wg`.
|
||||
$wgTool = $isAwg2 ? self::resolveWgTool($serverData, $containerName) : 'wg';
|
||||
$wgQuickTool = $wgTool . '-quick';
|
||||
|
||||
// 1. Create temp file for PSK (to avoid shell escaping issues)
|
||||
$pskFile = '/tmp/' . bin2hex(random_bytes(8)) . '.psk';
|
||||
@@ -1255,6 +1290,22 @@ class VpnClient
|
||||
// Without this, the interface uses standard WireGuard without Jc/S1/S2/H1-H4
|
||||
$cmd5 = sprintf("docker exec -i %s sh -c 'ip link del %s 2>/dev/null || true; %s up %s/%s 2>&1'", $containerName, $ifaceName, $wgQuickTool, $configDir, $configFile);
|
||||
self::executeServerCommand($serverData, $cmd5, true);
|
||||
|
||||
// 7. CRITICAL: Clamp TCP MSS so download-direction packets fit the reduced
|
||||
// AmneziaWG tunnel MTU (clients use MTU 1280 -> MSS 1240). Without this the
|
||||
// handshake succeeds and small packets flow, but large packets (web pages,
|
||||
// TLS responses) exceed the tunnel and are silently dropped — the classic
|
||||
// "connected but no traffic" symptom (issue #50). Idempotent (-C then -A),
|
||||
// and ip_forward is ensured for good measure. Re-applied on every client
|
||||
// creation so it survives container restarts/reinstalls.
|
||||
$mssRule = "-p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240";
|
||||
$cmd6 = sprintf(
|
||||
"docker exec -i %s sh -c 'sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true; iptables -t mangle -C FORWARD %s 2>/dev/null || iptables -t mangle -A FORWARD %s'",
|
||||
$containerName,
|
||||
$mssRule,
|
||||
$mssRule
|
||||
);
|
||||
self::executeServerCommand($serverData, $cmd6, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1292,11 +1343,40 @@ class VpnClient
|
||||
self::executeServerCommand($serverData, $updateCmd, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the WireGuard userspace tool name available inside a container.
|
||||
* Returns 'awg' when present, otherwise 'wg'. Used so AWG2 works on both the
|
||||
* official Amnezia image (ships `wg`) and amneziawg-go (ships `awg`).
|
||||
*/
|
||||
private static function resolveWgTool(array $serverData, string $containerName): string
|
||||
{
|
||||
$probe = sprintf(
|
||||
"docker exec -i %s sh -lc 'command -v awg >/dev/null 2>&1 && echo awg || echo wg'",
|
||||
escapeshellarg($containerName)
|
||||
);
|
||||
$tool = trim(self::executeServerCommand($serverData, $probe, true));
|
||||
return $tool === 'awg' ? 'awg' : 'wg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command on server
|
||||
*/
|
||||
private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string
|
||||
{
|
||||
// Delegate to VpnServer::executeCommand so SSH key authentication, docker
|
||||
// sudo auto-detection and retry logic are shared with the rest of the
|
||||
// panel. The previous inline implementation was password-only and failed
|
||||
// on key-based servers (contributing to issue #50).
|
||||
if (!empty($serverData['id'])) {
|
||||
try {
|
||||
$server = new VpnServer((int) $serverData['id']);
|
||||
return $server->executeCommand($command, $sudo ? null : false);
|
||||
} catch (Exception $e) {
|
||||
error_log('executeServerCommand: delegate failed, using legacy path: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback (no server id in $serverData): password-only SSH.
|
||||
$needsSudo = $sudo && strtolower((string) ($serverData['username'] ?? '')) !== 'root';
|
||||
$baseCommand = $command;
|
||||
|
||||
@@ -1308,8 +1388,8 @@ class VpnClient
|
||||
$run = static function (string $cmd) use ($serverData): string {
|
||||
$escapedCommand = escapeshellarg($cmd);
|
||||
$sshCommand = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1",
|
||||
$serverData['password'],
|
||||
"sshpass -p %s ssh -p %d -q -o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o PreferredAuthentications=password -o PubkeyAuthentication=no %s@%s %s 2>&1",
|
||||
escapeshellarg($serverData['password']),
|
||||
$serverData['port'],
|
||||
$serverData['username'],
|
||||
$serverData['host'],
|
||||
|
||||
+131
-13
@@ -9,6 +9,13 @@ class VpnServer
|
||||
private $serverId;
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* Cache for docker sudo requirements per server.
|
||||
* null = not tested, true = needs sudo, false = no sudo needed
|
||||
* @var array<int, bool|null>
|
||||
*/
|
||||
private static $dockerSudoCache = [];
|
||||
|
||||
public function __construct(?int $serverId = null)
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
@@ -44,6 +51,29 @@ class VpnServer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize SSH private key: fix line endings, trim whitespace, ensure trailing newline.
|
||||
* Fixes "error in libcrypto" when keys are pasted from Windows or browsers.
|
||||
*/
|
||||
private static function normalizeSshKey(string $key): string
|
||||
{
|
||||
// Remove \r (Windows line endings)
|
||||
$key = str_replace("\r\n", "\n", $key);
|
||||
$key = str_replace("\r", "\n", $key);
|
||||
// Trim each line (remove trailing spaces)
|
||||
$lines = explode("\n", $key);
|
||||
$lines = array_map('rtrim', $lines);
|
||||
// Remove empty lines at start/end but keep internal structure
|
||||
while (!empty($lines) && trim($lines[0]) === '') array_shift($lines);
|
||||
while (!empty($lines) && trim(end($lines)) === '') array_pop($lines);
|
||||
$key = implode("\n", $lines);
|
||||
// PEM/OpenSSH keys MUST end with a newline
|
||||
if ($key !== '' && substr($key, -1) !== "\n") {
|
||||
$key .= "\n";
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new VPN server in database
|
||||
*/
|
||||
@@ -88,7 +118,7 @@ class VpnServer
|
||||
$data['port'],
|
||||
$data['username'],
|
||||
$data['password'] ?? null,
|
||||
$data['ssh_key'] ?? null,
|
||||
!empty($data['ssh_key']) ? self::normalizeSshKey($data['ssh_key']) : null,
|
||||
$data['container_name'] ?? 'amnezia-awg',
|
||||
$protocolSlug,
|
||||
$installOptions,
|
||||
@@ -388,7 +418,7 @@ class VpnServer
|
||||
|
||||
if (!empty($this->data['ssh_key'])) {
|
||||
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey');
|
||||
file_put_contents($keyFile, $this->data['ssh_key']);
|
||||
file_put_contents($keyFile, self::normalizeSshKey($this->data['ssh_key']));
|
||||
chmod($keyFile, 0600);
|
||||
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
|
||||
// sshpass is not needed for key-based auth
|
||||
@@ -404,8 +434,8 @@ class VpnServer
|
||||
} else {
|
||||
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
|
||||
$testCommand = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s 'echo test' 2>/dev/null",
|
||||
$this->data['password'],
|
||||
"sshpass -p %s ssh -p %d %s %s@%s 'echo test' 2>/dev/null",
|
||||
escapeshellarg($this->data['password']),
|
||||
$this->data['port'],
|
||||
$sshOptions,
|
||||
$this->data['username'],
|
||||
@@ -423,12 +453,23 @@ class VpnServer
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command on remote server
|
||||
* Execute command on remote server.
|
||||
*
|
||||
* @param string $command The command to execute
|
||||
* @param bool|null $sudo True = use sudo, false = no sudo, null = auto-detect for docker commands
|
||||
* @return string The command output
|
||||
*/
|
||||
public function executeCommand(string $command, bool $sudo = false): string
|
||||
public function executeCommand(string $command, bool $sudo = null): string
|
||||
{
|
||||
$baseCommand = $command;
|
||||
$pathPrefix = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH; ';
|
||||
$isDockerCommand = preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand));
|
||||
|
||||
// Auto-detect sudo requirement for docker commands when $sudo is null
|
||||
if ($sudo === null && $isDockerCommand) {
|
||||
$sudo = $this->detectDockerSudoRequirement();
|
||||
}
|
||||
|
||||
$escapedCommand = '';
|
||||
$needsSudo = false;
|
||||
|
||||
@@ -438,7 +479,7 @@ class VpnServer
|
||||
|
||||
if (!empty($this->data['ssh_key'])) {
|
||||
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey');
|
||||
file_put_contents($keyFile, $this->data['ssh_key']);
|
||||
file_put_contents($keyFile, self::normalizeSshKey($this->data['ssh_key']));
|
||||
chmod($keyFile, 0600);
|
||||
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
|
||||
|
||||
@@ -454,7 +495,7 @@ class VpnServer
|
||||
$escapedCommand
|
||||
);
|
||||
} else {
|
||||
$needsSudo = $sudo && strtolower((string) ($this->data['username'] ?? '')) !== 'root';
|
||||
$needsSudo = ($sudo ?? false) && strtolower((string) ($this->data['username'] ?? '')) !== 'root';
|
||||
if ($needsSudo) {
|
||||
// Suppress sudo prompt text to keep command output machine-parseable.
|
||||
$command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command;
|
||||
@@ -465,8 +506,8 @@ class VpnServer
|
||||
|
||||
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
|
||||
$sshCommand = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1",
|
||||
$this->data['password'],
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>&1",
|
||||
escapeshellarg($this->data['password']),
|
||||
$this->data['port'],
|
||||
$sshOptions,
|
||||
$this->data['username'],
|
||||
@@ -481,13 +522,18 @@ class VpnServer
|
||||
if (
|
||||
empty($this->data['ssh_key'])
|
||||
&& !empty($needsSudo)
|
||||
&& preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand))
|
||||
&& $isDockerCommand
|
||||
&& preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output)
|
||||
) {
|
||||
// Update cache: this server doesn't need sudo for docker
|
||||
if ($this->serverId !== null) {
|
||||
self::$dockerSudoCache[$this->serverId] = false;
|
||||
}
|
||||
|
||||
$escapedBaseCommand = escapeshellarg($pathPrefix . $baseCommand);
|
||||
$sshCommandNoSudo = sprintf(
|
||||
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1",
|
||||
$this->data['password'],
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>&1",
|
||||
escapeshellarg($this->data['password']),
|
||||
$this->data['port'],
|
||||
$sshOptions,
|
||||
$this->data['username'],
|
||||
@@ -504,6 +550,78 @@ class VpnServer
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether docker commands require sudo on this server.
|
||||
* Uses a simple test command to check if docker works without sudo.
|
||||
* Results are cached per server instance.
|
||||
*
|
||||
* @return bool True if sudo is needed, false if docker works without sudo
|
||||
*/
|
||||
private function detectDockerSudoRequirement(): bool
|
||||
{
|
||||
// Return cached result if available
|
||||
if ($this->serverId !== null && array_key_exists($this->serverId, self::$dockerSudoCache)) {
|
||||
return self::$dockerSudoCache[$this->serverId];
|
||||
}
|
||||
|
||||
// Test if docker works without sudo using a simple version check
|
||||
$testCmd = 'docker --version 2>&1';
|
||||
$pathPrefix = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH; ';
|
||||
|
||||
$sshOptions = '-o LogLevel=ERROR -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no';
|
||||
$keyFile = '';
|
||||
|
||||
if (!empty($this->data['ssh_key'])) {
|
||||
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey');
|
||||
file_put_contents($keyFile, self::normalizeSshKey($this->data['ssh_key']));
|
||||
chmod($keyFile, 0600);
|
||||
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
|
||||
|
||||
$preparedCommand = $pathPrefix . $testCmd;
|
||||
$escapedCommand = escapeshellarg($preparedCommand);
|
||||
|
||||
$sshCommand = sprintf(
|
||||
"ssh -p %d %s %s@%s %s 2>&1",
|
||||
$this->data['port'],
|
||||
$sshOptions,
|
||||
$this->data['username'],
|
||||
$this->data['host'],
|
||||
$escapedCommand
|
||||
);
|
||||
} else {
|
||||
// For password auth, first try without sudo
|
||||
$preparedCommand = $pathPrefix . $testCmd;
|
||||
$escapedCommand = escapeshellarg($preparedCommand);
|
||||
|
||||
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
|
||||
$sshCommand = sprintf(
|
||||
"sshpass -p %s ssh -p %d %s %s@%s %s 2>&1",
|
||||
escapeshellarg($this->data['password']),
|
||||
$this->data['port'],
|
||||
$sshOptions,
|
||||
$this->data['username'],
|
||||
$this->data['host'],
|
||||
$escapedCommand
|
||||
);
|
||||
}
|
||||
|
||||
$output = shell_exec($sshCommand) ?? '';
|
||||
|
||||
if ($keyFile && file_exists($keyFile)) {
|
||||
unlink($keyFile);
|
||||
}
|
||||
|
||||
// Check if docker command succeeded (output contains "version")
|
||||
$dockerWorks = stripos($output, 'version') !== false;
|
||||
|
||||
// Cache the result
|
||||
if ($this->serverId !== null) {
|
||||
self::$dockerSudoCache[$this->serverId] = !$dockerWorks;
|
||||
}
|
||||
|
||||
return !$dockerWorks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Docker on remote server
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ SET output_template = '[Interface]
|
||||
Address = {{client_ip}}/32
|
||||
DNS = {{dns_servers}}
|
||||
PrivateKey = {{private_key}}
|
||||
MTU = 1280
|
||||
Jc = {{Jc}}
|
||||
Jmin = {{Jmin}}
|
||||
Jmax = {{Jmax}}
|
||||
@@ -144,8 +145,8 @@ echo "H2 = $H2_VAL"
|
||||
echo "H3 = $H3_VAL"
|
||||
echo "H4 = $H4_VAL"
|
||||
echo "I1 = $I1_VAL"
|
||||
echo "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE"
|
||||
echo "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE"
|
||||
echo "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240"
|
||||
echo "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -D FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240"
|
||||
} > /opt/amnezia/awg2/awg0.conf
|
||||
|
||||
echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
-- =====================================================================
|
||||
-- Migration 066: Add Cloudflare WARP proxy protocol
|
||||
-- Installs Cloudflare WARP on VPS and enables SOCKS5/HTTPS proxy mode
|
||||
-- Creates chain: AmneziaWG → WARP (127.0.0.1:40000) → Internet
|
||||
-- Adds DPI/censorship bypass layer via Cloudflare tunnel
|
||||
-- =====================================================================
|
||||
|
||||
-- 1. Insert the Cloudflare WARP protocol
|
||||
INSERT INTO protocols (name, slug, description, install_script, uninstall_script, output_template, show_text_content, ubuntu_compatible, is_active, definition, created_at, updated_at)
|
||||
SELECT
|
||||
'Cloudflare WARP Proxy',
|
||||
'cf-warp',
|
||||
'Cloudflare WARP — прокси-слой для обхода DPI/цензуры. Устанавливает WARP на сервер в режиме SOCKS5 прокси (127.0.0.1:40000). Трафик идёт по цепочке: VPN-клиент → AmneziaWG → WARP → Cloudflare → Интернет. Скрывает конечные домены от провайдера VPS.',
|
||||
'#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# ======================================================================
|
||||
# Cloudflare WARP Proxy Installer
|
||||
# Installs WARP in proxy mode (SOCKS5 on 127.0.0.1:40000)
|
||||
# For chain: AmneziaWG → WARP → Internet
|
||||
# ======================================================================
|
||||
|
||||
WARP_PROXY_PORT="${WARP_PROXY_PORT:-40000}"
|
||||
WARP_MODE="${WARP_MODE:-proxy}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "=== Installing Cloudflare WARP ==="
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_VERSION="$VERSION_ID"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
OS_VERSION="0"
|
||||
fi
|
||||
|
||||
echo "Detected OS: $OS_ID $OS_VERSION"
|
||||
|
||||
# Check architecture
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then
|
||||
echo "ERROR: WARP supports only x86_64 and aarch64, got: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install prerequisites
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl gnupg lsb-release >/dev/null 2>&1
|
||||
|
||||
# Add Cloudflare WARP repository
|
||||
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||
|
||||
# Determine correct repo codename
|
||||
REPO_CODENAME=""
|
||||
case "$OS_ID" in
|
||||
ubuntu)
|
||||
case "$OS_VERSION" in
|
||||
24.04) REPO_CODENAME="noble" ;;
|
||||
22.04) REPO_CODENAME="jammy" ;;
|
||||
20.04) REPO_CODENAME="focal" ;;
|
||||
*) REPO_CODENAME="jammy" ;;
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
case "$OS_VERSION" in
|
||||
12*) REPO_CODENAME="bookworm" ;;
|
||||
11*) REPO_CODENAME="bullseye" ;;
|
||||
*) REPO_CODENAME="bookworm" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
REPO_CODENAME="jammy"
|
||||
echo "WARNING: Unsupported OS $OS_ID, trying Ubuntu Jammy repo"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $REPO_CODENAME main" > /etc/apt/sources.list.d/cloudflare-client.list
|
||||
|
||||
# Install WARP client
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflare-warp >/dev/null 2>&1
|
||||
|
||||
echo "WARP package installed"
|
||||
|
||||
# Check if already registered
|
||||
WARP_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "unregistered")
|
||||
|
||||
if echo "$WARP_STATUS" | grep -qiE "Registration Missing|unregistered"; then
|
||||
echo "Registering WARP..."
|
||||
warp-cli --accept-tos registration new
|
||||
echo "WARP registered"
|
||||
else
|
||||
echo "WARP already registered"
|
||||
fi
|
||||
|
||||
# Set proxy mode
|
||||
echo "Setting WARP to proxy mode on port $WARP_PROXY_PORT..."
|
||||
warp-cli --accept-tos mode proxy
|
||||
warp-cli --accept-tos proxy port "$WARP_PROXY_PORT"
|
||||
|
||||
# Connect WARP
|
||||
echo "Connecting WARP..."
|
||||
warp-cli --accept-tos connect
|
||||
|
||||
# Wait for connection
|
||||
for i in $(seq 1 15); do
|
||||
CONN_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "")
|
||||
if echo "$CONN_STATUS" | grep -qi "Connected"; then
|
||||
echo "WARP connected successfully"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 15 ]; then
|
||||
echo "WARNING: WARP connection timeout, may still be connecting..."
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify proxy is listening
|
||||
sleep 2
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
LISTENING=$(ss -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
LISTENING=$(netstat -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
else
|
||||
LISTENING=""
|
||||
fi
|
||||
|
||||
if [ -n "$LISTENING" ]; then
|
||||
echo "WARP SOCKS5 proxy listening on 127.0.0.1:${WARP_PROXY_PORT}"
|
||||
else
|
||||
echo "WARNING: Proxy port ${WARP_PROXY_PORT} not yet listening, WARP may need more time"
|
||||
fi
|
||||
|
||||
# Test proxy connectivity
|
||||
PROXY_TEST=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s -o /dev/null -w "%{http_code}" --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo "000")
|
||||
if [ "$PROXY_TEST" = "200" ]; then
|
||||
echo "WARP proxy test: OK (HTTP 200)"
|
||||
else
|
||||
echo "WARNING: WARP proxy test returned HTTP $PROXY_TEST (may need a moment to initialize)"
|
||||
fi
|
||||
|
||||
# Get WARP IP info
|
||||
WARP_IP=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep "ip=" | cut -d= -f2 || echo "unknown")
|
||||
WARP_ACCOUNT=$(warp-cli --accept-tos registration show 2>/dev/null | grep -i "Account ID" | awk "{print \$NF}" || echo "unknown")
|
||||
|
||||
# Enable WARP service to start on boot
|
||||
systemctl enable warp-svc 2>/dev/null || true
|
||||
|
||||
# Get server external IP
|
||||
EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
|
||||
|
||||
echo ""
|
||||
echo "=== Cloudflare WARP Proxy Installed ==="
|
||||
echo "Variable: warp_proxy_port=$WARP_PROXY_PORT"
|
||||
echo "Variable: warp_mode=$WARP_MODE"
|
||||
echo "Variable: warp_ip=$WARP_IP"
|
||||
echo "Variable: warp_account=$WARP_ACCOUNT"
|
||||
echo "Variable: server_host=$EXTERNAL_IP"
|
||||
echo "Variable: proxy_address=127.0.0.1:${WARP_PROXY_PORT}"',
|
||||
'#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
echo "=== Uninstalling Cloudflare WARP ==="
|
||||
|
||||
# Disconnect and deregister
|
||||
warp-cli --accept-tos disconnect 2>/dev/null || true
|
||||
warp-cli --accept-tos registration delete 2>/dev/null || true
|
||||
|
||||
# Stop service
|
||||
systemctl stop warp-svc 2>/dev/null || true
|
||||
systemctl disable warp-svc 2>/dev/null || true
|
||||
|
||||
# Remove package
|
||||
apt-get remove -y cloudflare-warp 2>/dev/null || true
|
||||
apt-get autoremove -y 2>/dev/null || true
|
||||
|
||||
# Clean up config
|
||||
rm -rf /var/lib/cloudflare-warp 2>/dev/null || true
|
||||
rm -f /etc/apt/sources.list.d/cloudflare-client.list 2>/dev/null || true
|
||||
rm -f /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg 2>/dev/null || true
|
||||
|
||||
echo "{\"success\":true,\"message\":\"Cloudflare WARP uninstalled\"}"',
|
||||
'WARP SOCKS5 Proxy: socks5h://127.0.0.1:{{warp_proxy_port}}
|
||||
WARP IP: {{warp_ip}}
|
||||
Mode: {{warp_mode}}
|
||||
Server: {{server_host}}',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
JSON_OBJECT(
|
||||
'engine', 'shell',
|
||||
'metadata', JSON_OBJECT(
|
||||
'container_name', '',
|
||||
'port_range', JSON_ARRAY(40000, 40000),
|
||||
'config_dir', '/var/lib/cloudflare-warp',
|
||||
'is_proxy_layer', true,
|
||||
'proxy_port', 40000,
|
||||
'proxy_protocol', 'socks5'
|
||||
)
|
||||
),
|
||||
NOW(),
|
||||
NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM protocols WHERE slug = 'cf-warp');
|
||||
|
||||
-- 2. Add protocol variables for WARP
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'warp_proxy_port', 'number', '40000', 'WARP SOCKS5 proxy port (default 40000)', true
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'warp_proxy_port');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'warp_mode', 'string', 'proxy', 'WARP mode (proxy / warp)', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'warp_mode');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'warp_ip', 'string', '', 'WARP exit IP address (via Cloudflare)', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'warp_ip');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'warp_account', 'string', '', 'WARP account ID', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'warp_account');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'server_host', 'string', '', 'Server hostname or IP', true
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'server_host');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'proxy_address', 'string', '127.0.0.1:40000', 'Full proxy address', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'proxy_address');
|
||||
|
||||
-- 3. Add default template for WARP
|
||||
INSERT INTO protocol_templates (protocol_id, template_name, template_content, is_default)
|
||||
SELECT p.id, 'Default WARP', 'WARP SOCKS5 Proxy: socks5h://127.0.0.1:{{warp_proxy_port}}
|
||||
WARP IP: {{warp_ip}}
|
||||
Mode: {{warp_mode}}
|
||||
Server: {{server_host}}', true
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_templates WHERE protocol_id = p.id AND template_name = 'Default WARP');
|
||||
|
||||
-- 4. Add translations for Cloudflare WARP
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'protocol_cf_warp', 'Cloudflare WARP Proxy')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'protocol_cf_warp', 'Cloudflare WARP Прокси')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
-- WARP-specific UI translations
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'warp_status', 'WARP Status')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'warp_status', 'Статус WARP')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'warp_connected', 'Connected via Cloudflare')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'warp_connected', 'Подключён через Cloudflare')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'warp_disconnected', 'Disconnected')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'warp_disconnected', 'Отключён')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'warp_proxy_info', 'WARP proxy adds a Cloudflare encryption layer to hide destination domains from VPS provider. Traffic chain: Client → AmneziaWG → WARP → Cloudflare → Internet')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'warp_proxy_info', 'WARP прокси добавляет слой шифрования Cloudflare для скрытия конечных доменов от провайдера VPS. Цепочка: Клиент → AmneziaWG → WARP → Cloudflare → Интернет')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('en', 'protocols', 'warp_warning_ram', '⚠️ Cloudflare WARP uses ~50-100MB additional RAM')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
|
||||
INSERT INTO translations (locale, category, key_name, translation) VALUES
|
||||
('ru', 'protocols', 'warp_warning_ram', '⚠️ Cloudflare WARP использует ~50-100 МБ дополнительной RAM')
|
||||
ON DUPLICATE KEY UPDATE translation = VALUES(translation);
|
||||
@@ -0,0 +1,402 @@
|
||||
-- =====================================================================
|
||||
-- Migration 067: WARP auto-integration with redsocks + iptables
|
||||
-- Automatically routes all VPN client TCP traffic through WARP proxy
|
||||
-- Chain: VPN clients (10.8.x.0/24) → redsocks → WARP SOCKS5 → Cloudflare
|
||||
-- Also detects X-Ray and patches its outbound config
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET install_script = '#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# ======================================================================
|
||||
# Cloudflare WARP Proxy Installer (v2 — with auto-routing)
|
||||
# Installs WARP + redsocks, auto-routes VPN client traffic through CF
|
||||
# Chain: VPN clients → redsocks → WARP SOCKS5 → Cloudflare → Internet
|
||||
# ======================================================================
|
||||
|
||||
WARP_PROXY_PORT="${WARP_PROXY_PORT:-40000}"
|
||||
WARP_MODE="${WARP_MODE:-proxy}"
|
||||
REDSOCKS_PORT="${REDSOCKS_PORT:-12345}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "=== Installing Cloudflare WARP (v2 with auto-routing) ==="
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_VERSION="$VERSION_ID"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
OS_VERSION="0"
|
||||
fi
|
||||
|
||||
echo "Detected OS: $OS_ID $OS_VERSION"
|
||||
|
||||
# Check architecture
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then
|
||||
echo "ERROR: WARP supports only x86_64 and aarch64, got: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check available RAM (warn if < 512MB)
|
||||
TOTAL_RAM_MB=$(free -m 2>/dev/null | awk "/^Mem:/{print \\$2}" || echo "0")
|
||||
if [ "$TOTAL_RAM_MB" -gt 0 ] && [ "$TOTAL_RAM_MB" -lt 512 ]; then
|
||||
echo "WARNING: Server has only ${TOTAL_RAM_MB}MB RAM. WARP needs ~100MB. Consider upgrading."
|
||||
fi
|
||||
|
||||
# Install prerequisites
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl gnupg lsb-release >/dev/null 2>&1
|
||||
|
||||
# Add Cloudflare WARP repository
|
||||
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||
|
||||
# Determine correct repo codename
|
||||
REPO_CODENAME=""
|
||||
case "$OS_ID" in
|
||||
ubuntu)
|
||||
case "$OS_VERSION" in
|
||||
24.04) REPO_CODENAME="noble" ;;
|
||||
22.04) REPO_CODENAME="jammy" ;;
|
||||
20.04) REPO_CODENAME="focal" ;;
|
||||
*) REPO_CODENAME="jammy" ;;
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
case "$OS_VERSION" in
|
||||
12*) REPO_CODENAME="bookworm" ;;
|
||||
11*) REPO_CODENAME="bullseye" ;;
|
||||
*) REPO_CODENAME="bookworm" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
REPO_CODENAME="jammy"
|
||||
echo "WARNING: Unsupported OS $OS_ID, trying Ubuntu Jammy repo"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $REPO_CODENAME main" > /etc/apt/sources.list.d/cloudflare-client.list
|
||||
|
||||
# Install WARP client
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflare-warp >/dev/null 2>&1
|
||||
|
||||
echo "WARP package installed"
|
||||
|
||||
# Check if already registered
|
||||
WARP_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "unregistered")
|
||||
|
||||
if echo "$WARP_STATUS" | grep -qiE "Registration Missing|unregistered"; then
|
||||
echo "Registering WARP..."
|
||||
warp-cli --accept-tos registration new
|
||||
echo "WARP registered"
|
||||
else
|
||||
echo "WARP already registered"
|
||||
fi
|
||||
|
||||
# Set proxy mode
|
||||
echo "Setting WARP to proxy mode on port $WARP_PROXY_PORT..."
|
||||
warp-cli --accept-tos mode proxy
|
||||
warp-cli --accept-tos proxy port "$WARP_PROXY_PORT"
|
||||
|
||||
# Connect WARP
|
||||
echo "Connecting WARP..."
|
||||
warp-cli --accept-tos connect
|
||||
|
||||
# Wait for connection
|
||||
for i in $(seq 1 15); do
|
||||
CONN_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "")
|
||||
if echo "$CONN_STATUS" | grep -qi "Connected"; then
|
||||
echo "WARP connected successfully"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 15 ]; then
|
||||
echo "WARNING: WARP connection timeout, may still be connecting..."
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify proxy is listening
|
||||
sleep 2
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
LISTENING=$(ss -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
LISTENING=$(netstat -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
else
|
||||
LISTENING=""
|
||||
fi
|
||||
|
||||
if [ -n "$LISTENING" ]; then
|
||||
echo "WARP SOCKS5 proxy listening on 127.0.0.1:${WARP_PROXY_PORT}"
|
||||
else
|
||||
echo "WARNING: Proxy port ${WARP_PROXY_PORT} not yet listening, WARP may need more time"
|
||||
fi
|
||||
|
||||
# Test proxy connectivity
|
||||
PROXY_TEST=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s -o /dev/null -w "%{http_code}" --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo "000")
|
||||
if [ "$PROXY_TEST" = "200" ]; then
|
||||
echo "WARP proxy test: OK (HTTP 200)"
|
||||
else
|
||||
echo "WARNING: WARP proxy test returned HTTP $PROXY_TEST (may need a moment to initialize)"
|
||||
fi
|
||||
|
||||
# Get WARP IP info
|
||||
WARP_IP=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep "ip=" | cut -d= -f2 || echo "unknown")
|
||||
WARP_ACCOUNT=$(warp-cli --accept-tos registration show 2>/dev/null | grep -i "Account ID" | awk "{print \\$NF}" || echo "unknown")
|
||||
|
||||
# Enable WARP service to start on boot
|
||||
systemctl enable warp-svc 2>/dev/null || true
|
||||
|
||||
# ======================================================================
|
||||
# AUTO-ROUTING: Install redsocks + iptables rules
|
||||
# Routes all VPN client TCP traffic through WARP
|
||||
# ======================================================================
|
||||
echo ""
|
||||
echo "=== Setting up auto-routing (redsocks) ==="
|
||||
|
||||
ROUTED_SUBNETS=""
|
||||
|
||||
# Install redsocks
|
||||
apt-get install -y -qq redsocks >/dev/null 2>&1 || {
|
||||
echo "WARNING: redsocks not available in repos, trying manual install"
|
||||
apt-get install -y -qq gcc libevent-dev make git >/dev/null 2>&1 || true
|
||||
if [ ! -f /usr/local/bin/redsocks ]; then
|
||||
cd /tmp
|
||||
git clone --depth=1 https://github.com/darkk/redsocks.git redsocks-build 2>/dev/null || true
|
||||
if [ -d redsocks-build ]; then
|
||||
cd redsocks-build && make -j$(nproc) 2>/dev/null && cp redsocks /usr/local/bin/redsocks && chmod +x /usr/local/bin/redsocks
|
||||
cd / && rm -rf /tmp/redsocks-build
|
||||
echo "redsocks built from source"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
REDSOCKS_BIN=$(command -v redsocks 2>/dev/null || echo "")
|
||||
if [ -z "$REDSOCKS_BIN" ] && [ -f /usr/local/bin/redsocks ]; then
|
||||
REDSOCKS_BIN="/usr/local/bin/redsocks"
|
||||
fi
|
||||
|
||||
if [ -n "$REDSOCKS_BIN" ]; then
|
||||
echo "redsocks found: $REDSOCKS_BIN"
|
||||
|
||||
# Create redsocks config
|
||||
mkdir -p /etc/redsocks
|
||||
cat > /etc/redsocks/redsocks.conf << REDSOCKS_EOF
|
||||
base {
|
||||
log_debug = off;
|
||||
log_info = on;
|
||||
log = "syslog:daemon";
|
||||
daemon = on;
|
||||
redirector = iptables;
|
||||
}
|
||||
redsocks {
|
||||
local_ip = 127.0.0.1;
|
||||
local_port = ${REDSOCKS_PORT};
|
||||
ip = 127.0.0.1;
|
||||
port = ${WARP_PROXY_PORT};
|
||||
type = socks5;
|
||||
}
|
||||
REDSOCKS_EOF
|
||||
|
||||
# Create systemd service for redsocks
|
||||
cat > /etc/systemd/system/redsocks-warp.service << SYSTEMD_EOF
|
||||
[Unit]
|
||||
Description=Redsocks WARP transparent proxy
|
||||
After=network.target warp-svc.service
|
||||
Wants=warp-svc.service
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
ExecStart=${REDSOCKS_BIN} -c /etc/redsocks/redsocks.conf
|
||||
ExecStop=/bin/kill -TERM \$MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SYSTEMD_EOF
|
||||
|
||||
# Stop any existing redsocks
|
||||
systemctl stop redsocks 2>/dev/null || true
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
killall redsocks 2>/dev/null || true
|
||||
|
||||
# Start redsocks
|
||||
systemctl daemon-reload
|
||||
systemctl enable redsocks-warp 2>/dev/null || true
|
||||
systemctl start redsocks-warp
|
||||
echo "redsocks-warp service started on port ${REDSOCKS_PORT}"
|
||||
|
||||
# Detect VPN subnets to route
|
||||
# 1. AWG subnets from running containers
|
||||
for container in $(docker ps --format "{{.Names}}" 2>/dev/null | grep -iE "amnezia-awg|awg" || true); do
|
||||
SUBNET=$(docker exec "$container" cat /opt/amnezia/awg/wg0.conf 2>/dev/null | grep -oP "Address\s*=\s*\K[0-9./]+" | head -1 || true)
|
||||
if [ -z "$SUBNET" ]; then
|
||||
SUBNET=$(docker exec "$container" cat /opt/amnezia/awg/awg0.conf 2>/dev/null | grep -oP "Address\s*=\s*\K[0-9./]+" | head -1 || true)
|
||||
fi
|
||||
if [ -n "$SUBNET" ]; then
|
||||
# Convert server IP/mask to network: 10.8.1.1/24 -> 10.8.1.0/24
|
||||
NET=$(echo "$SUBNET" | sed -E "s/([0-9]+\\.[0-9]+\\.[0-9]+)\\.[0-9]+/\\1.0/")
|
||||
ROUTED_SUBNETS="$ROUTED_SUBNETS $NET"
|
||||
echo "Detected AWG subnet from $container: $NET"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Check server vpn_subnet from panel config
|
||||
if [ -z "$ROUTED_SUBNETS" ]; then
|
||||
# Fallback: common Amnezia subnets
|
||||
ROUTED_SUBNETS="10.8.1.0/24"
|
||||
echo "Using default AWG subnet: 10.8.1.0/24"
|
||||
fi
|
||||
|
||||
# Setup iptables REDSOCKS chain
|
||||
echo "Setting up iptables rules for subnets:$ROUTED_SUBNETS"
|
||||
|
||||
iptables -t nat -N REDSOCKS_WARP 2>/dev/null || iptables -t nat -F REDSOCKS_WARP
|
||||
|
||||
# Skip local/private destinations
|
||||
iptables -t nat -A REDSOCKS_WARP -d 0.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 10.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 100.64.0.0/10 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 127.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 169.254.0.0/16 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 172.16.0.0/12 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 192.168.0.0/16 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 224.0.0.0/4 -j RETURN
|
||||
iptables -t nat -A REDSOCKS_WARP -d 240.0.0.0/4 -j RETURN
|
||||
|
||||
# Redirect remaining TCP to redsocks
|
||||
iptables -t nat -A REDSOCKS_WARP -p tcp -j REDIRECT --to-ports ${REDSOCKS_PORT}
|
||||
|
||||
# Apply REDSOCKS_WARP chain to VPN subnets
|
||||
for SUBNET in $ROUTED_SUBNETS; do
|
||||
# Remove old rule if exists
|
||||
iptables -t nat -D PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
# Add new rule
|
||||
iptables -t nat -A PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP
|
||||
echo "Routing $SUBNET TCP traffic through WARP"
|
||||
done
|
||||
|
||||
# Save iptables rules for persistence
|
||||
if command -v netfilter-persistent >/dev/null 2>&1; then
|
||||
netfilter-persistent save 2>/dev/null || true
|
||||
elif command -v iptables-save >/dev/null 2>&1; then
|
||||
mkdir -p /etc/iptables
|
||||
iptables-save > /etc/iptables/rules.v4 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Save routed subnets for uninstall cleanup
|
||||
echo "$ROUTED_SUBNETS" > /var/lib/cloudflare-warp/routed_subnets
|
||||
|
||||
echo "Auto-routing configured: VPN client TCP traffic now goes through WARP"
|
||||
else
|
||||
echo "WARNING: redsocks not available, skipping auto-routing."
|
||||
echo "VPN clients will NOT automatically route through WARP."
|
||||
echo "Manual SOCKS5 proxy: socks5h://127.0.0.1:${WARP_PROXY_PORT}"
|
||||
fi
|
||||
|
||||
# ======================================================================
|
||||
# X-Ray integration: patch outbound config if X-Ray is running
|
||||
# ======================================================================
|
||||
XRAY_CONTAINER=$(docker ps --format "{{.Names}}" 2>/dev/null | grep -i "xray" | head -1 || true)
|
||||
if [ -n "$XRAY_CONTAINER" ]; then
|
||||
echo ""
|
||||
echo "=== Detected X-Ray container: $XRAY_CONTAINER ==="
|
||||
XRAY_CONFIG=$(docker exec "$XRAY_CONTAINER" cat /etc/xray/config.json 2>/dev/null || echo "")
|
||||
if [ -n "$XRAY_CONFIG" ]; then
|
||||
# Check if warp-out already configured
|
||||
if echo "$XRAY_CONFIG" | grep -q "warp-out"; then
|
||||
echo "X-Ray already has warp-out outbound, skipping"
|
||||
else
|
||||
echo "NOTE: X-Ray detected but auto-patching disabled for safety."
|
||||
echo "To route X-Ray traffic through WARP, add this outbound manually:"
|
||||
echo " {\"tag\":\"warp-out\",\"protocol\":\"socks\",\"settings\":{\"servers\":[{\"address\":\"127.0.0.1\",\"port\":${WARP_PROXY_PORT}}]}}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get server external IP
|
||||
EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
|
||||
|
||||
echo ""
|
||||
echo "=== Cloudflare WARP Proxy Installed ==="
|
||||
echo "Variable: warp_proxy_port=$WARP_PROXY_PORT"
|
||||
echo "Variable: warp_mode=$WARP_MODE"
|
||||
echo "Variable: warp_ip=$WARP_IP"
|
||||
echo "Variable: warp_account=$WARP_ACCOUNT"
|
||||
echo "Variable: server_host=$EXTERNAL_IP"
|
||||
echo "Variable: proxy_address=127.0.0.1:${WARP_PROXY_PORT}"
|
||||
echo "Variable: redsocks_port=$REDSOCKS_PORT"
|
||||
echo "Variable: routed_subnets=$ROUTED_SUBNETS"',
|
||||
uninstall_script = '#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
echo "=== Uninstalling Cloudflare WARP ==="
|
||||
|
||||
# ── Remove iptables rules ──
|
||||
ROUTED_SUBNETS=""
|
||||
if [ -f /var/lib/cloudflare-warp/routed_subnets ]; then
|
||||
ROUTED_SUBNETS=$(cat /var/lib/cloudflare-warp/routed_subnets)
|
||||
fi
|
||||
if [ -z "$ROUTED_SUBNETS" ]; then
|
||||
ROUTED_SUBNETS="10.8.1.0/24"
|
||||
fi
|
||||
|
||||
for SUBNET in $ROUTED_SUBNETS; do
|
||||
iptables -t nat -D PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
done
|
||||
iptables -t nat -F REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -X REDSOCKS_WARP 2>/dev/null || true
|
||||
echo "iptables REDSOCKS_WARP chain removed"
|
||||
|
||||
# Save cleaned iptables
|
||||
if command -v netfilter-persistent >/dev/null 2>&1; then
|
||||
netfilter-persistent save 2>/dev/null || true
|
||||
elif command -v iptables-save >/dev/null 2>&1; then
|
||||
mkdir -p /etc/iptables
|
||||
iptables-save > /etc/iptables/rules.v4 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Stop and remove redsocks ──
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
systemctl disable redsocks-warp 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/redsocks-warp.service
|
||||
rm -rf /etc/redsocks
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
echo "redsocks-warp service removed"
|
||||
|
||||
# ── Disconnect and deregister WARP ──
|
||||
warp-cli --accept-tos disconnect 2>/dev/null || true
|
||||
warp-cli --accept-tos registration delete 2>/dev/null || true
|
||||
|
||||
# Stop service
|
||||
systemctl stop warp-svc 2>/dev/null || true
|
||||
systemctl disable warp-svc 2>/dev/null || true
|
||||
|
||||
# Remove package
|
||||
apt-get remove -y cloudflare-warp 2>/dev/null || true
|
||||
apt-get autoremove -y 2>/dev/null || true
|
||||
|
||||
# Clean up config
|
||||
rm -rf /var/lib/cloudflare-warp 2>/dev/null || true
|
||||
rm -f /etc/apt/sources.list.d/cloudflare-client.list 2>/dev/null || true
|
||||
rm -f /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg 2>/dev/null || true
|
||||
|
||||
echo "{\"success\":true,\"message\":\"Cloudflare WARP + redsocks uninstalled\"}"',
|
||||
updated_at = NOW()
|
||||
WHERE slug = 'cf-warp';
|
||||
|
||||
-- Add new protocol variables for redsocks integration
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'redsocks_port', 'number', '12345', 'Redsocks transparent proxy port', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'redsocks_port');
|
||||
|
||||
INSERT INTO protocol_variables (protocol_id, variable_name, variable_type, default_value, description, required)
|
||||
SELECT p.id, 'routed_subnets', 'string', '10.8.1.0/24', 'VPN subnets routed through WARP', false
|
||||
FROM protocols p WHERE p.slug = 'cf-warp'
|
||||
AND NOT EXISTS (SELECT 1 FROM protocol_variables WHERE protocol_id = p.id AND variable_name = 'routed_subnets');
|
||||
@@ -0,0 +1,246 @@
|
||||
-- =====================================================================
|
||||
-- Migration 068: Fix WARP install script for panel heredoc compatibility
|
||||
-- Fixes: nested heredoc conflict, set -eo pipefail duplication,
|
||||
-- docker format template conflict with panel wrapper
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET install_script = '#!/bin/bash
|
||||
|
||||
# ======================================================================
|
||||
# Cloudflare WARP Proxy Installer v3 (panel-compatible)
|
||||
# Installs WARP + redsocks, auto-routes VPN client traffic through CF
|
||||
# ======================================================================
|
||||
|
||||
WARP_PROXY_PORT="${WARP_PROXY_PORT:-40000}"
|
||||
WARP_MODE="${WARP_MODE:-proxy}"
|
||||
REDSOCKS_PORT="${REDSOCKS_PORT:-12345}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "=== Installing Cloudflare WARP v3 ==="
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_VERSION="$VERSION_ID"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
OS_VERSION="0"
|
||||
fi
|
||||
echo "OS: $OS_ID $OS_VERSION"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then
|
||||
echo "FAIL: WARP supports only x86_64 and aarch64, got: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL_RAM_MB=$(free -m 2>/dev/null | awk "/^Mem:/{print \$2}" || echo "0")
|
||||
if [ "$TOTAL_RAM_MB" -gt 0 ] && [ "$TOTAL_RAM_MB" -lt 512 ]; then
|
||||
echo "NOTE: Low RAM ${TOTAL_RAM_MB}MB. WARP needs ~100MB."
|
||||
fi
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl gnupg lsb-release >/dev/null 2>&1
|
||||
|
||||
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||
|
||||
REPO_CODENAME=""
|
||||
case "$OS_ID" in
|
||||
ubuntu)
|
||||
case "$OS_VERSION" in
|
||||
24.04) REPO_CODENAME="noble" ;;
|
||||
22.04) REPO_CODENAME="jammy" ;;
|
||||
20.04) REPO_CODENAME="focal" ;;
|
||||
*) REPO_CODENAME="jammy" ;;
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
case "$OS_VERSION" in
|
||||
12*) REPO_CODENAME="bookworm" ;;
|
||||
11*) REPO_CODENAME="bullseye" ;;
|
||||
*) REPO_CODENAME="bookworm" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
REPO_CODENAME="jammy"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $REPO_CODENAME main" > /etc/apt/sources.list.d/cloudflare-client.list
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflare-warp >/dev/null 2>&1
|
||||
echo "WARP package installed"
|
||||
|
||||
WARP_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "unregistered")
|
||||
if echo "$WARP_STATUS" | grep -qiE "Registration Missing|unregistered"; then
|
||||
warp-cli --accept-tos registration new
|
||||
echo "WARP registered"
|
||||
else
|
||||
echo "WARP already registered"
|
||||
fi
|
||||
|
||||
warp-cli --accept-tos mode proxy
|
||||
warp-cli --accept-tos proxy port "$WARP_PROXY_PORT"
|
||||
warp-cli --accept-tos connect
|
||||
echo "WARP connecting..."
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
CONN_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "")
|
||||
if echo "$CONN_STATUS" | grep -qi "Connected"; then
|
||||
echo "WARP connected"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
sleep 2
|
||||
LISTENING=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
LISTENING=$(ss -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
fi
|
||||
if [ -n "$LISTENING" ]; then
|
||||
echo "WARP SOCKS5 proxy on 127.0.0.1:${WARP_PROXY_PORT} OK"
|
||||
else
|
||||
echo "NOTE: Proxy port ${WARP_PROXY_PORT} not yet listening"
|
||||
fi
|
||||
|
||||
PROXY_TEST=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s -o /dev/null -w "%{http_code}" --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo "000")
|
||||
if [ "$PROXY_TEST" = "200" ]; then
|
||||
echo "WARP proxy test OK"
|
||||
fi
|
||||
|
||||
WARP_IP=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep "ip=" | cut -d= -f2 || echo "unknown")
|
||||
WARP_ACCOUNT=$(warp-cli --accept-tos registration show 2>/dev/null | grep -i "Account ID" | awk "{print \$NF}" || echo "unknown")
|
||||
|
||||
systemctl enable warp-svc 2>/dev/null || true
|
||||
|
||||
# ── AUTO-ROUTING: redsocks + iptables ──
|
||||
echo "=== Setting up redsocks auto-routing ==="
|
||||
|
||||
apt-get install -y -qq redsocks >/dev/null 2>&1 || true
|
||||
|
||||
REDSOCKS_BIN=$(command -v redsocks 2>/dev/null || echo "")
|
||||
if [ -z "$REDSOCKS_BIN" ]; then
|
||||
echo "NOTE: redsocks package not installed, trying /usr/sbin"
|
||||
if [ -f /usr/sbin/redsocks ]; then
|
||||
REDSOCKS_BIN="/usr/sbin/redsocks"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$REDSOCKS_BIN" ]; then
|
||||
echo "redsocks binary: $REDSOCKS_BIN"
|
||||
|
||||
mkdir -p /etc/redsocks
|
||||
printf "base {\\n log_debug = off;\\n log_info = on;\\n log = \\"syslog:daemon\\";\\n daemon = on;\\n redirector = iptables;\\n}\\nredsocks {\\n local_ip = 127.0.0.1;\\n local_port = %s;\\n ip = 127.0.0.1;\\n port = %s;\\n type = socks5;\\n}\\n" "$REDSOCKS_PORT" "$WARP_PROXY_PORT" > /etc/redsocks/redsocks.conf
|
||||
echo "redsocks config created"
|
||||
|
||||
printf "[Unit]\\nDescription=Redsocks WARP transparent proxy\\nAfter=network.target warp-svc.service\\nWants=warp-svc.service\\n\\n[Service]\\nType=forking\\nExecStart=%s -c /etc/redsocks/redsocks.conf\\nRestart=on-failure\\nRestartSec=5\\n\\n[Install]\\nWantedBy=multi-user.target\\n" "$REDSOCKS_BIN" > /etc/systemd/system/redsocks-warp.service
|
||||
echo "systemd service created"
|
||||
|
||||
systemctl stop redsocks 2>/dev/null || true
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
killall redsocks 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
systemctl enable redsocks-warp 2>/dev/null || true
|
||||
systemctl start redsocks-warp 2>/dev/null || true
|
||||
echo "redsocks-warp started"
|
||||
|
||||
ROUTED_SUBNETS=""
|
||||
for container in $(docker ps --format "{{`{{.Names}}`}}" 2>/dev/null | grep -iE "amnezia-awg|awg" || true); do
|
||||
SUBNET=$(docker exec "$container" cat /opt/amnezia/awg/wg0.conf 2>/dev/null | grep -oP "Address\\s*=\\s*\\K[0-9./]+" | head -1 || true)
|
||||
if [ -z "$SUBNET" ]; then
|
||||
SUBNET=$(docker exec "$container" cat /opt/amnezia/awg/awg0.conf 2>/dev/null | grep -oP "Address\\s*=\\s*\\K[0-9./]+" | head -1 || true)
|
||||
fi
|
||||
if [ -n "$SUBNET" ]; then
|
||||
NET=$(echo "$SUBNET" | sed -E "s/([0-9]+\\.[0-9]+\\.[0-9]+)\\.[0-9]+/\\1.0/")
|
||||
ROUTED_SUBNETS="$ROUTED_SUBNETS $NET"
|
||||
echo "AWG subnet from $container: $NET"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$ROUTED_SUBNETS" ]; then
|
||||
ROUTED_SUBNETS="10.8.1.0/24"
|
||||
echo "Default subnet: 10.8.1.0/24"
|
||||
fi
|
||||
|
||||
iptables -t nat -N REDSOCKS_WARP 2>/dev/null || iptables -t nat -F REDSOCKS_WARP
|
||||
iptables -t nat -A REDSOCKS_WARP -d 0.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 10.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 100.64.0.0/10 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 127.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 169.254.0.0/16 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 172.16.0.0/12 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 192.168.0.0/16 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 224.0.0.0/4 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 240.0.0.0/4 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -p tcp -j REDIRECT --to-ports ${REDSOCKS_PORT} 2>/dev/null || true
|
||||
|
||||
for SUBNET in $ROUTED_SUBNETS; do
|
||||
iptables -t nat -D PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -A PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
echo "Routing $SUBNET through WARP"
|
||||
done
|
||||
|
||||
mkdir -p /var/lib/cloudflare-warp
|
||||
echo "$ROUTED_SUBNETS" > /var/lib/cloudflare-warp/routed_subnets
|
||||
echo "Auto-routing active"
|
||||
else
|
||||
echo "NOTE: redsocks not available, auto-routing skipped"
|
||||
echo "Manual proxy: socks5h://127.0.0.1:${WARP_PROXY_PORT}"
|
||||
fi
|
||||
|
||||
EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
|
||||
|
||||
echo ""
|
||||
echo "=== Cloudflare WARP Installed ==="
|
||||
echo "Variable: warp_proxy_port=$WARP_PROXY_PORT"
|
||||
echo "Variable: warp_mode=$WARP_MODE"
|
||||
echo "Variable: warp_ip=$WARP_IP"
|
||||
echo "Variable: warp_account=$WARP_ACCOUNT"
|
||||
echo "Variable: server_host=$EXTERNAL_IP"
|
||||
echo "Variable: proxy_address=127.0.0.1:${WARP_PROXY_PORT}"
|
||||
echo "Variable: redsocks_port=$REDSOCKS_PORT"
|
||||
echo "Variable: routed_subnets=$ROUTED_SUBNETS"',
|
||||
uninstall_script = '#!/bin/bash
|
||||
|
||||
echo "=== Uninstalling Cloudflare WARP ==="
|
||||
|
||||
ROUTED_SUBNETS=""
|
||||
if [ -f /var/lib/cloudflare-warp/routed_subnets ]; then
|
||||
ROUTED_SUBNETS=$(cat /var/lib/cloudflare-warp/routed_subnets)
|
||||
fi
|
||||
if [ -z "$ROUTED_SUBNETS" ]; then
|
||||
ROUTED_SUBNETS="10.8.1.0/24"
|
||||
fi
|
||||
|
||||
for SUBNET in $ROUTED_SUBNETS; do
|
||||
iptables -t nat -D PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
done
|
||||
iptables -t nat -F REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -X REDSOCKS_WARP 2>/dev/null || true
|
||||
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
systemctl disable redsocks-warp 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/redsocks-warp.service 2>/dev/null || true
|
||||
rm -rf /etc/redsocks 2>/dev/null || true
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
warp-cli --accept-tos disconnect 2>/dev/null || true
|
||||
warp-cli --accept-tos registration delete 2>/dev/null || true
|
||||
systemctl stop warp-svc 2>/dev/null || true
|
||||
systemctl disable warp-svc 2>/dev/null || true
|
||||
|
||||
apt-get remove -y cloudflare-warp 2>/dev/null || true
|
||||
apt-get autoremove -y 2>/dev/null || true
|
||||
|
||||
rm -rf /var/lib/cloudflare-warp 2>/dev/null || true
|
||||
rm -f /etc/apt/sources.list.d/cloudflare-client.list 2>/dev/null || true
|
||||
rm -f /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg 2>/dev/null || true
|
||||
|
||||
echo "{\"success\":true,\"message\":\"WARP + redsocks removed\"}"',
|
||||
updated_at = NOW()
|
||||
WHERE slug = 'cf-warp';
|
||||
@@ -0,0 +1,237 @@
|
||||
-- =====================================================================
|
||||
-- Migration 069: WARP auto-detect AIVPN subnet for routing
|
||||
-- Adds aivpn0 interface subnet detection alongside AWG containers
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET install_script = '#!/bin/bash
|
||||
|
||||
# ======================================================================
|
||||
# Cloudflare WARP Proxy Installer v4 (AWG + AIVPN + X-Ray)
|
||||
# Installs WARP + redsocks, auto-routes ALL VPN client traffic through CF
|
||||
# ======================================================================
|
||||
|
||||
WARP_PROXY_PORT="${WARP_PROXY_PORT:-40000}"
|
||||
WARP_MODE="${WARP_MODE:-proxy}"
|
||||
REDSOCKS_PORT="${REDSOCKS_PORT:-12345}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "=== Installing Cloudflare WARP v4 ==="
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_VERSION="$VERSION_ID"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
OS_VERSION="0"
|
||||
fi
|
||||
echo "OS: $OS_ID $OS_VERSION"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then
|
||||
echo "FAIL: WARP supports only x86_64 and aarch64, got: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL_RAM_MB=$(free -m 2>/dev/null | awk "/^Mem:/{print \$2}" || echo "0")
|
||||
if [ "$TOTAL_RAM_MB" -gt 0 ] && [ "$TOTAL_RAM_MB" -lt 512 ]; then
|
||||
echo "NOTE: Low RAM ${TOTAL_RAM_MB}MB. WARP needs ~100MB."
|
||||
fi
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl gnupg lsb-release >/dev/null 2>&1
|
||||
|
||||
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||
|
||||
REPO_CODENAME=""
|
||||
case "$OS_ID" in
|
||||
ubuntu)
|
||||
case "$OS_VERSION" in
|
||||
24.04) REPO_CODENAME="noble" ;;
|
||||
22.04) REPO_CODENAME="jammy" ;;
|
||||
20.04) REPO_CODENAME="focal" ;;
|
||||
*) REPO_CODENAME="jammy" ;;
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
case "$OS_VERSION" in
|
||||
12*) REPO_CODENAME="bookworm" ;;
|
||||
11*) REPO_CODENAME="bullseye" ;;
|
||||
*) REPO_CODENAME="bookworm" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
REPO_CODENAME="jammy"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $REPO_CODENAME main" > /etc/apt/sources.list.d/cloudflare-client.list
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflare-warp >/dev/null 2>&1
|
||||
echo "WARP package installed"
|
||||
|
||||
WARP_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "unregistered")
|
||||
if echo "$WARP_STATUS" | grep -qiE "Registration Missing|unregistered"; then
|
||||
warp-cli --accept-tos registration new
|
||||
echo "WARP registered"
|
||||
else
|
||||
echo "WARP already registered"
|
||||
fi
|
||||
|
||||
warp-cli --accept-tos mode proxy
|
||||
warp-cli --accept-tos proxy port "$WARP_PROXY_PORT"
|
||||
warp-cli --accept-tos connect
|
||||
echo "WARP connecting..."
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
CONN_STATUS=$(warp-cli --accept-tos status 2>/dev/null || echo "")
|
||||
if echo "$CONN_STATUS" | grep -qi "Connected"; then
|
||||
echo "WARP connected"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
sleep 2
|
||||
LISTENING=""
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
LISTENING=$(ss -tlnp 2>/dev/null | grep ":${WARP_PROXY_PORT}" || true)
|
||||
fi
|
||||
if [ -n "$LISTENING" ]; then
|
||||
echo "WARP SOCKS5 proxy on 127.0.0.1:${WARP_PROXY_PORT} OK"
|
||||
else
|
||||
echo "NOTE: Proxy port ${WARP_PROXY_PORT} not yet listening"
|
||||
fi
|
||||
|
||||
PROXY_TEST=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s -o /dev/null -w "%{http_code}" --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null || echo "000")
|
||||
if [ "$PROXY_TEST" = "200" ]; then
|
||||
echo "WARP proxy test OK"
|
||||
fi
|
||||
|
||||
WARP_IP=$(curl -x socks5h://127.0.0.1:${WARP_PROXY_PORT} -s --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep "ip=" | cut -d= -f2 || echo "unknown")
|
||||
WARP_ACCOUNT=$(warp-cli --accept-tos registration show 2>/dev/null | grep -i "Account ID" | awk "{print \$NF}" || echo "unknown")
|
||||
|
||||
systemctl enable warp-svc 2>/dev/null || true
|
||||
|
||||
# ── AUTO-ROUTING: redsocks + iptables ──
|
||||
echo "=== Setting up redsocks auto-routing ==="
|
||||
|
||||
apt-get install -y -qq redsocks >/dev/null 2>&1 || true
|
||||
|
||||
REDSOCKS_BIN=$(command -v redsocks 2>/dev/null || echo "")
|
||||
if [ -z "$REDSOCKS_BIN" ]; then
|
||||
if [ -f /usr/sbin/redsocks ]; then
|
||||
REDSOCKS_BIN="/usr/sbin/redsocks"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$REDSOCKS_BIN" ]; then
|
||||
echo "redsocks binary: $REDSOCKS_BIN"
|
||||
|
||||
mkdir -p /etc/redsocks
|
||||
printf "base {\\n log_debug = off;\\n log_info = on;\\n log = \\"syslog:daemon\\";\\n daemon = on;\\n redirector = iptables;\\n}\\nredsocks {\\n local_ip = 0.0.0.0;\\n local_port = %s;\\n ip = 127.0.0.1;\\n port = %s;\\n type = socks5;\\n}\\n" "$REDSOCKS_PORT" "$WARP_PROXY_PORT" > /etc/redsocks/redsocks.conf
|
||||
echo "redsocks config created"
|
||||
|
||||
printf "[Unit]\\nDescription=Redsocks WARP transparent proxy\\nAfter=network.target warp-svc.service\\nWants=warp-svc.service\\n\\n[Service]\\nType=forking\\nExecStart=%s -c /etc/redsocks/redsocks.conf\\nRestart=on-failure\\nRestartSec=5\\n\\n[Install]\\nWantedBy=multi-user.target\\n" "$REDSOCKS_BIN" > /etc/systemd/system/redsocks-warp.service
|
||||
echo "systemd service created"
|
||||
|
||||
systemctl stop redsocks 2>/dev/null || true
|
||||
systemctl stop redsocks-warp 2>/dev/null || true
|
||||
killall redsocks 2>/dev/null || true
|
||||
systemctl daemon-reload
|
||||
systemctl enable redsocks-warp 2>/dev/null || true
|
||||
systemctl start redsocks-warp 2>/dev/null || true
|
||||
echo "redsocks-warp started"
|
||||
|
||||
ROUTED_SUBNETS=""
|
||||
CONTAINER_IPS=""
|
||||
|
||||
# 1. Detect AWG container IPs (containers MASQUERADE VPN traffic,
|
||||
# so on the host we see src=container_IP, not VPN subnet)
|
||||
for container in $(docker ps --format "{{`{{.Names}}`}}" 2>/dev/null | grep -iE "amnezia-awg|awg" || true); do
|
||||
CIP=$(docker inspect -f "{{`{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}`}}" "$container" 2>/dev/null || true)
|
||||
if [ -n "$CIP" ]; then
|
||||
CONTAINER_IPS="$CONTAINER_IPS $CIP"
|
||||
echo "AWG container $container IP: $CIP"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Detect host-level VPN subnets (aivpn0, wg0, etc.)
|
||||
for iface in aivpn0 wg0 wg1; do
|
||||
ADDR=$(ip -4 addr show "$iface" 2>/dev/null | grep -oP "inet \\K[0-9./]+" | head -1 || true)
|
||||
if [ -n "$ADDR" ]; then
|
||||
NET=$(echo "$ADDR" | sed -E "s/([0-9]+\\.[0-9]+\\.[0-9]+)\\.[0-9]+(.*)/\\1.0\\2/")
|
||||
if ! echo "$ROUTED_SUBNETS" | grep -q "$NET"; then
|
||||
ROUTED_SUBNETS="$ROUTED_SUBNETS $NET"
|
||||
echo "VPN subnet from $iface: $NET"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Fallback if nothing detected
|
||||
if [ -z "$ROUTED_SUBNETS" ] && [ -z "$CONTAINER_IPS" ]; then
|
||||
ROUTED_SUBNETS="10.8.1.0/24 10.0.0.0/24"
|
||||
echo "Default subnets: 10.8.1.0/24 10.0.0.0/24"
|
||||
fi
|
||||
|
||||
# Enable route_localnet so DNAT to 127.0.0.1 works for Docker traffic
|
||||
sysctl -w net.ipv4.conf.docker0.route_localnet=1 2>/dev/null || true
|
||||
sysctl -w net.ipv4.conf.all.route_localnet=1 2>/dev/null || true
|
||||
grep -q route_localnet /etc/sysctl.d/99-warp.conf 2>/dev/null || {
|
||||
mkdir -p /etc/sysctl.d
|
||||
echo "net.ipv4.conf.docker0.route_localnet=1" >> /etc/sysctl.d/99-warp.conf
|
||||
echo "net.ipv4.conf.all.route_localnet=1" >> /etc/sysctl.d/99-warp.conf
|
||||
}
|
||||
|
||||
iptables -t nat -N REDSOCKS_WARP 2>/dev/null || iptables -t nat -F REDSOCKS_WARP
|
||||
iptables -t nat -A REDSOCKS_WARP -d 0.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 10.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 100.64.0.0/10 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 127.0.0.0/8 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 169.254.0.0/16 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 172.16.0.0/12 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 192.168.0.0/16 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 224.0.0.0/4 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -d 240.0.0.0/4 -j RETURN 2>/dev/null || true
|
||||
iptables -t nat -A REDSOCKS_WARP -p tcp -j REDIRECT --to-ports ${REDSOCKS_PORT} 2>/dev/null || true
|
||||
|
||||
# Add rules for host-level VPN subnets
|
||||
for SUBNET in $ROUTED_SUBNETS; do
|
||||
iptables -t nat -D PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -A PREROUTING -s "$SUBNET" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
echo "Routing subnet $SUBNET through WARP"
|
||||
done
|
||||
|
||||
# Add rules for AWG container IPs (traffic exits container MASQUERADEd)
|
||||
for CIP in $CONTAINER_IPS; do
|
||||
iptables -t nat -D PREROUTING -s "$CIP" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
iptables -t nat -A PREROUTING -s "$CIP" -p tcp -j REDSOCKS_WARP 2>/dev/null || true
|
||||
echo "Routing container $CIP through WARP"
|
||||
done
|
||||
|
||||
mkdir -p /var/lib/cloudflare-warp
|
||||
echo "$ROUTED_SUBNETS $CONTAINER_IPS" > /var/lib/cloudflare-warp/routed_subnets
|
||||
echo "Auto-routing active"
|
||||
else
|
||||
echo "NOTE: redsocks not available, auto-routing skipped"
|
||||
echo "Manual proxy: socks5h://127.0.0.1:${WARP_PROXY_PORT}"
|
||||
fi
|
||||
|
||||
EXTERNAL_IP=$(curl -s -4 ifconfig.me 2>/dev/null || curl -s -4 icanhazip.com 2>/dev/null || echo "YOUR_SERVER_IP")
|
||||
|
||||
echo ""
|
||||
echo "=== Cloudflare WARP Installed ==="
|
||||
echo "Variable: warp_proxy_port=$WARP_PROXY_PORT"
|
||||
echo "Variable: warp_mode=$WARP_MODE"
|
||||
echo "Variable: warp_ip=$WARP_IP"
|
||||
echo "Variable: warp_account=$WARP_ACCOUNT"
|
||||
echo "Variable: server_host=$EXTERNAL_IP"
|
||||
echo "Variable: proxy_address=127.0.0.1:${WARP_PROXY_PORT}"
|
||||
echo "Variable: redsocks_port=$REDSOCKS_PORT"
|
||||
echo "Variable: routed_subnets=$ROUTED_SUBNETS"',
|
||||
updated_at = NOW()
|
||||
WHERE slug = 'cf-warp';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- =====================================================================
|
||||
-- Migration 070: Speed up / stabilize AmneziaWG 2.0 (awg2) installation
|
||||
--
|
||||
-- Issue #50: the first install of awg2 frequently failed with
|
||||
-- "Invalid server response". Root cause: the install script ran
|
||||
-- `docker build --no-cache` every time, forcing a full recompile of the
|
||||
-- amneziawg-go Go sources on each attempt. That build can take several
|
||||
-- minutes, exceeding the web request timeout, so the browser received a
|
||||
-- truncated (non-JSON) response. On retry the work from the first attempt
|
||||
-- had already produced the image/config, so it "magically" succeeded.
|
||||
--
|
||||
-- Dropping `--no-cache` lets Docker reuse cached layers, making installs
|
||||
-- (and especially retries) fast and idempotent. The sources are pinned via
|
||||
-- `git clone --depth=1`, so a cached build is the desired behaviour.
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET install_script = REPLACE(install_script, 'docker build --no-cache -t amnezia-awg2', 'docker build -t amnezia-awg2')
|
||||
WHERE slug = 'awg2'
|
||||
AND install_script LIKE '%docker build --no-cache -t amnezia-awg2%';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- =====================================================================
|
||||
-- Migration 071: Restore client MTU for AmneziaWG 2.0 (awg2)
|
||||
--
|
||||
-- Issue #50: clients connect (handshake succeeds) but no traffic flows.
|
||||
-- Root cause: the awg2 client output_template lost its "MTU = 1280" line
|
||||
-- when migration 064 rewrote it (migration 058 had it). With no explicit
|
||||
-- MTU the client defaults to 1420, which is too large once AmneziaWG
|
||||
-- obfuscation overhead (Jc junk packets, S1/S2 padding) is added on top of
|
||||
-- WireGuard's own overhead: the handshake (small packets) succeeds, but
|
||||
-- larger packets (TLS, web pages) exceed the path and are dropped — so the
|
||||
-- tunnel is "connected" yet carries no usable traffic. 1280 is the value the
|
||||
-- official Amnezia app uses for AmneziaWG clients.
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET output_template = REPLACE(
|
||||
output_template,
|
||||
'PrivateKey = {{private_key}}\n',
|
||||
'PrivateKey = {{private_key}}\nMTU = 1280\n'
|
||||
)
|
||||
WHERE slug = 'awg2'
|
||||
AND output_template LIKE '%PrivateKey = {{private_key}}%'
|
||||
AND output_template NOT LIKE '%MTU%';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- =====================================================================
|
||||
-- Migration 072: TCP MSS clamping for AmneziaWG 2.0 (awg2)
|
||||
--
|
||||
-- Issue #50: clients connect (handshake OK) but no traffic flows. With the
|
||||
-- reduced tunnel MTU (clients use 1280), TCP must also negotiate a small
|
||||
-- enough MSS, otherwise full-size download packets (web pages, TLS responses)
|
||||
-- exceed the tunnel and are dropped — the handshake and small packets work,
|
||||
-- but browsing stalls. Clamping MSS to 1240 (1280 - 40) on the server's
|
||||
-- FORWARD path fixes the download direction.
|
||||
--
|
||||
-- This appends the clamp to the awg2 install script's PostUp so panel-installed
|
||||
-- servers get it on every interface bring-up. (Adopted native containers are
|
||||
-- handled at runtime by VpnClient::addClientToServer(), which applies the same
|
||||
-- rule idempotently on each client creation.)
|
||||
-- =====================================================================
|
||||
|
||||
UPDATE protocols
|
||||
SET install_script = REPLACE(
|
||||
install_script,
|
||||
'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE',
|
||||
'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240'
|
||||
)
|
||||
WHERE slug = 'awg2'
|
||||
AND install_script LIKE '%-A POSTROUTING -o eth0 -j MASQUERADE%'
|
||||
AND install_script NOT LIKE '%TCPMSS%';
|
||||
@@ -568,6 +568,13 @@ Router::post('/servers/{id}/deploy', function ($params) {
|
||||
requireAuth();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Some protocols (e.g. AmneziaWG 2.0 / awg2) build a Docker image from source
|
||||
// on the remote host, which can take several minutes. Without lifting the PHP
|
||||
// time limit the request is killed mid-build and the browser receives a
|
||||
// truncated, non-JSON body shown as "Invalid server response" (issue #50).
|
||||
@set_time_limit(0);
|
||||
@ignore_user_abort(true);
|
||||
|
||||
$serverId = (int) $params['id'];
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$options = [];
|
||||
@@ -763,6 +770,71 @@ Router::post('/servers/{id}/protocols/activate', function ($params) {
|
||||
}
|
||||
});
|
||||
|
||||
// Get WARP status for a server (AJAX)
|
||||
Router::get('/servers/{id}/warp/status', function ($params) {
|
||||
requireAuth();
|
||||
header('Content-Type: application/json');
|
||||
$serverId = (int) $params['id'];
|
||||
try {
|
||||
$server = new VpnServer($serverId);
|
||||
$serverData = $server->getData();
|
||||
$user = Auth::user();
|
||||
if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
$status = InstallProtocolManager::getWarpStatus($server);
|
||||
echo json_encode(array_merge(['success' => true], $status));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
});
|
||||
|
||||
// WARP actions: connect/disconnect/reconnect (AJAX)
|
||||
Router::post('/servers/{id}/warp/action', function ($params) {
|
||||
requireAdmin();
|
||||
header('Content-Type: application/json');
|
||||
$serverId = (int) $params['id'];
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
if (!in_array($action, ['connect', 'disconnect', 'reconnect'], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid action. Allowed: connect, disconnect, reconnect']);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$server = new VpnServer($serverId);
|
||||
$serverData = $server->getData();
|
||||
$user = Auth::user();
|
||||
if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
switch ($action) {
|
||||
case 'connect':
|
||||
$server->executeCommand('warp-cli --accept-tos connect 2>/dev/null', true);
|
||||
break;
|
||||
case 'disconnect':
|
||||
$server->executeCommand('warp-cli --accept-tos disconnect 2>/dev/null', true);
|
||||
break;
|
||||
case 'reconnect':
|
||||
$server->executeCommand('warp-cli --accept-tos disconnect 2>/dev/null || true', true);
|
||||
sleep(1);
|
||||
$server->executeCommand('warp-cli --accept-tos connect 2>/dev/null', true);
|
||||
break;
|
||||
}
|
||||
sleep(2);
|
||||
$status = InstallProtocolManager::getWarpStatus($server);
|
||||
echo json_encode(array_merge(['success' => true, 'action' => $action], $status));
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
});
|
||||
|
||||
// View server
|
||||
Router::get('/servers/{id}', function ($params) {
|
||||
requireAuth();
|
||||
@@ -1613,6 +1685,65 @@ Router::post('/clients/{id}/sync-stats', function ($params) {
|
||||
}
|
||||
});
|
||||
|
||||
// Set client expiration (web session auth)
|
||||
Router::post('/clients/{id}/set-expiration', function ($params) {
|
||||
requireAuth();
|
||||
header('Content-Type: application/json');
|
||||
$clientId = (int) $params['id'];
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
$expiresAt = $data['expires_at'] ?? null;
|
||||
|
||||
try {
|
||||
$client = new VpnClient($clientId);
|
||||
$clientData = $client->getData();
|
||||
|
||||
$user = Auth::user();
|
||||
if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
VpnClient::setExpiration($clientId, $expiresAt);
|
||||
echo json_encode(['success' => true, 'expires_at' => $expiresAt]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
});
|
||||
|
||||
// Set client traffic limit (web session auth)
|
||||
Router::post('/clients/{id}/set-traffic-limit', function ($params) {
|
||||
requireAuth();
|
||||
header('Content-Type: application/json');
|
||||
$clientId = (int) $params['id'];
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
$limitBytes = isset($data['traffic_limit']) ? (int) $data['traffic_limit'] : null;
|
||||
|
||||
try {
|
||||
$client = new VpnClient($clientId);
|
||||
$clientData = $client->getData();
|
||||
|
||||
$user = Auth::user();
|
||||
if ($clientData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Forbidden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo = DB::conn();
|
||||
$stmt = $pdo->prepare('UPDATE vpn_clients SET traffic_limit = ? WHERE id = ?');
|
||||
$stmt->execute([$limitBytes, $clientId]);
|
||||
echo json_encode(['success' => true, 'traffic_limit' => $limitBytes]);
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
});
|
||||
// Sync all stats for server
|
||||
Router::post('/servers/{id}/sync-stats', function ($params) {
|
||||
requireAuth();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/sh
|
||||
# =====================================================================
|
||||
# Downgrade an AmneziaWG server's obfuscation to a "classic" (AmneziaWG 1.0)
|
||||
# set that older router AmneziaWG implementations accept.
|
||||
#
|
||||
# Keeps : Jc, Jmin, Jmax, S1, S2 (widely supported AWG 1.0 junk params)
|
||||
# Converts: H1-H4 from "a-b" ranges -> single value "a"
|
||||
# Drops : S3, S4 and I1-I5 (AWG 1.5/2.0-only padding & magic packets)
|
||||
#
|
||||
# After running this you MUST regenerate every client config in the panel
|
||||
# (create new clients / re-export) and re-import them on phones too — the old
|
||||
# AWG 2.0 client configs no longer match the server and will stop connecting.
|
||||
#
|
||||
# Usage (on the VPS host that runs the container):
|
||||
# sh awg_downgrade_obfuscation.sh [container_name]
|
||||
# Defaults to container "amnezia-awg2".
|
||||
# =====================================================================
|
||||
set -e
|
||||
|
||||
CONTAINER="${1:-amnezia-awg2}"
|
||||
|
||||
if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
echo "Container '$CONTAINER' not found. Pass the correct name as the 1st arg." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Locate the config inside the container (awg0.conf for AWG2, wg0.conf legacy).
|
||||
CONF=""
|
||||
for f in /opt/amnezia/awg/awg0.conf /opt/amnezia/awg/wg0.conf /etc/wireguard/wg0.conf; do
|
||||
if docker exec "$CONTAINER" test -f "$f" 2>/dev/null; then CONF="$f"; break; fi
|
||||
done
|
||||
[ -n "$CONF" ] || { echo "WireGuard config not found inside $CONTAINER" >&2; exit 1; }
|
||||
|
||||
echo "Container : $CONTAINER"
|
||||
echo "Config : $CONF"
|
||||
echo "Before:"
|
||||
docker exec "$CONTAINER" sh -c "grep -E '^(Jc|Jmin|Jmax|S[0-9]|H[0-9]|I[0-9])[[:space:]]*=' '$CONF' || true"
|
||||
|
||||
# Rewrite the [Interface] obfuscation params, then reload the interface using
|
||||
# whichever tool the image provides (awg on amneziawg-go, wg on the Amnezia image).
|
||||
docker exec "$CONTAINER" sh -c '
|
||||
set -e
|
||||
CONF="'"$CONF"'"
|
||||
IFACE="$(basename "$CONF" .conf)"
|
||||
cp "$CONF" "${CONF}.bak" 2>/dev/null || true
|
||||
|
||||
# H1-H4: "a-b" -> "a"
|
||||
sed -i -E "s/^([[:space:]]*H[1-4][[:space:]]*=[[:space:]]*[0-9]+)-[0-9]+/\1/" "$CONF"
|
||||
# Drop S3, S4 and I1-I5 lines entirely
|
||||
sed -i -E "/^[[:space:]]*(S3|S4|I[1-5])[[:space:]]*=/d" "$CONF"
|
||||
|
||||
QUICK="$(command -v awg-quick || command -v wg-quick)"
|
||||
"$QUICK" down "$CONF" 2>/dev/null || "$QUICK" down "$IFACE" 2>/dev/null || true
|
||||
"$QUICK" up "$CONF"
|
||||
'
|
||||
|
||||
echo "After:"
|
||||
docker exec "$CONTAINER" sh -c "grep -E '^(Jc|Jmin|Jmax|S[0-9]|H[0-9]|I[0-9])[[:space:]]*=' '$CONF' || true"
|
||||
echo "Done. Now regenerate all client configs in the panel and re-import them."
|
||||
@@ -239,7 +239,7 @@ async function updateExpiration(event, clientId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/clients/${clientId}/set-expiration`, {
|
||||
const response = await fetch(`/clients/${clientId}/set-expiration`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -250,7 +250,7 @@ async function updateExpiration(event, clientId) {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success !== false) {
|
||||
if (response.ok && data.success === true) {
|
||||
alert('Expiration updated successfully');
|
||||
document.getElementById('currentExpiration').textContent = displayText;
|
||||
// Reset form
|
||||
@@ -293,18 +293,18 @@ async function updateTrafficLimit(event, clientId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/clients/${clientId}/set-traffic-limit`, {
|
||||
const response = await fetch(`/clients/${clientId}/set-traffic-limit`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ limit_bytes: limitBytes })
|
||||
body: JSON.stringify({ traffic_limit: limitBytes })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success !== false) {
|
||||
if (response.ok && data.success === true) {
|
||||
alert('Traffic limit updated successfully');
|
||||
// Reload page to show updated traffic info
|
||||
window.location.reload();
|
||||
|
||||
+220
-21
@@ -98,6 +98,59 @@
|
||||
{% if sp.server_host %}<span>Host: {{ sp.server_host }}</span>{% endif %}
|
||||
{% if sp.server_port %}<span class="ml-2">Port: {{ sp.server_port }}</span>{% endif %}
|
||||
</div>
|
||||
{% if sp.slug == 'cf-warp' %}
|
||||
{# ── WARP Status Widget ── #}
|
||||
<div id="warpStatusWidget" class="mt-3 p-3 rounded-lg" style="background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="#F59E0B"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-white">Cloudflare WARP</span>
|
||||
</div>
|
||||
<div id="warpStatusBadge">
|
||||
<span class="px-2 py-0.5 rounded text-xs bg-gray-600 text-gray-300">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>Загрузка...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="warpDetails" class="space-y-1 text-xs">
|
||||
<div class="flex items-center justify-between text-gray-400">
|
||||
<span>Прокси</span>
|
||||
<span id="warpProxy" class="font-mono text-gray-300">—</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-gray-400">
|
||||
<span>WARP IP</span>
|
||||
<span id="warpExitIp" class="font-mono text-gray-300">—</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-gray-400">
|
||||
<span>Режим</span>
|
||||
<span id="warpMode" class="text-gray-300">—</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-gray-400">
|
||||
<span>Сервис</span>
|
||||
<span id="warpSvc" class="text-gray-300">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button id="warpBtnConnect" onclick="warpAction('connect')" class="flex-1 px-2 py-1 rounded text-xs font-medium text-white" style="background:#22c55e;opacity:0.9" disabled>
|
||||
<i class="fas fa-play mr-1"></i>Connect
|
||||
</button>
|
||||
<button id="warpBtnDisconnect" onclick="warpAction('disconnect')" class="flex-1 px-2 py-1 rounded text-xs font-medium text-white" style="background:#ef4444;opacity:0.9" disabled>
|
||||
<i class="fas fa-stop mr-1"></i>Disconnect
|
||||
</button>
|
||||
<button id="warpBtnReconnect" onclick="warpAction('reconnect')" class="flex-1 px-2 py-1 rounded text-xs font-medium text-white" style="background:#3b82f6;opacity:0.9" disabled>
|
||||
<i class="fas fa-sync-alt mr-1"></i>Reconnect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] text-gray-500 mt-2 leading-tight">
|
||||
⚠️ WARP ~50-100 МБ RAM • Цепочка: Клиент → WG → WARP → CF → Интернет
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-500">Нет установленных протоколов</div>
|
||||
@@ -412,29 +465,42 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
if (uninstallAllBtn) {
|
||||
uninstallAllBtn.addEventListener('click', async function(e) {
|
||||
console.log('uninstallAllBtn clicked');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) {
|
||||
console.log('User canceled');
|
||||
return;
|
||||
}
|
||||
console.log('Starting uninstall all...');
|
||||
if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) return;
|
||||
|
||||
const origHTML = uninstallAllBtn.innerHTML;
|
||||
uninstallAllBtn.disabled = true;
|
||||
msg.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление всех контейнеров...</span>';
|
||||
uninstallAllBtn.innerHTML = '<i class="fas fa-circle-notch fa-spin mr-1"></i>Удаление...';
|
||||
uninstallAllBtn.className = uninstallAllBtn.className.replace('bg-gray-600', 'bg-yellow-600');
|
||||
uninstallAllBtn.style.cursor = 'wait';
|
||||
msg.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;margin-top:4px;">' +
|
||||
'<i class="fas fa-circle-notch fa-spin text-yellow-600"></i>' +
|
||||
'<span style="color:#92400e;font-weight:500;">Удаление всех протоколов... Это может занять минуту</span></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
console.log('Response:', data);
|
||||
if (data.success) {
|
||||
msg.textContent = data.message || 'Успешно';
|
||||
uninstallAllBtn.innerHTML = '<i class="fas fa-check mr-1"></i>Удалено';
|
||||
uninstallAllBtn.className = uninstallAllBtn.className.replace('bg-yellow-600', 'bg-green-600');
|
||||
msg.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#d1fae5;border:1px solid #10b981;border-radius:6px;margin-top:4px;">' +
|
||||
'<i class="fas fa-check-circle text-green-600"></i>' +
|
||||
'<span style="color:#065f46;font-weight:500;">' + (data.message || 'Все протоколы удалены') + '</span></div>';
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} else {
|
||||
msg.textContent = data.error || 'Ошибка';
|
||||
uninstallAllBtn.innerHTML = origHTML;
|
||||
uninstallAllBtn.className = uninstallAllBtn.className.replace('bg-yellow-600', 'bg-gray-600');
|
||||
msg.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fee2e2;border:1px solid #ef4444;border-radius:6px;margin-top:4px;">' +
|
||||
'<i class="fas fa-exclamation-circle text-red-600"></i>' +
|
||||
'<span style="color:#991b1b;">' + (data.error || 'Ошибка') + '</span></div>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
msg.textContent = e.message;
|
||||
uninstallAllBtn.innerHTML = origHTML;
|
||||
uninstallAllBtn.className = uninstallAllBtn.className.replace('bg-yellow-600', 'bg-gray-600');
|
||||
msg.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fee2e2;border:1px solid #ef4444;border-radius:6px;margin-top:4px;">' +
|
||||
'<i class="fas fa-exclamation-circle text-red-600"></i>' +
|
||||
'<span style="color:#991b1b;">' + (e.message || 'Ошибка связи') + '</span></div>';
|
||||
}
|
||||
uninstallAllBtn.disabled = false;
|
||||
});
|
||||
@@ -481,24 +547,71 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!confirmed) return;
|
||||
const slug = btn.getAttribute('data-slug');
|
||||
const m = document.getElementById('uninstallSpMsg');
|
||||
m.textContent = '';
|
||||
btn.disabled = true;
|
||||
m.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление протокола...</span>';
|
||||
const card = btn.closest('.border.rounded');
|
||||
|
||||
// Save original button state
|
||||
const origHTML = btn.innerHTML;
|
||||
const origClasses = btn.className;
|
||||
|
||||
// Disable ALL uninstall buttons
|
||||
document.querySelectorAll('.btn-uninstall-sp').forEach(b => { b.disabled = true; b.style.opacity = '0.5'; });
|
||||
|
||||
// Animate the clicked button
|
||||
btn.style.opacity = '1';
|
||||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin mr-1"></i>Удаление...';
|
||||
btn.className = btn.className.replace('bg-red-600', 'bg-yellow-600');
|
||||
btn.style.cursor = 'wait';
|
||||
btn.style.minWidth = btn.offsetWidth + 'px';
|
||||
|
||||
// Add overlay to protocol card
|
||||
if (card) {
|
||||
card.style.position = 'relative';
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'uninstall-overlay-' + slug;
|
||||
overlay.style.cssText = 'position:absolute;inset:0;background:rgba(239,68,68,0.05);border-radius:inherit;pointer-events:none;z-index:5;';
|
||||
card.appendChild(overlay);
|
||||
card.style.transition = 'opacity 0.3s';
|
||||
card.style.opacity = '0.7';
|
||||
}
|
||||
|
||||
// Show progress message
|
||||
m.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;margin-top:8px;">' +
|
||||
'<i class="fas fa-circle-notch fa-spin text-yellow-600"></i>' +
|
||||
'<span style="color:#92400e;font-weight:500;">Удаление <b>' + slug + '</b>... Это может занять до 30 секунд</span></div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' });
|
||||
let data;
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; }
|
||||
if (resp.ok && data && !data.error) {
|
||||
m.textContent = 'Удалено. Клиенты: ' + (data.clients_removed || 0);
|
||||
setTimeout(() => location.reload(), 800);
|
||||
// Success state
|
||||
btn.innerHTML = '<i class="fas fa-check mr-1"></i>Удалено';
|
||||
btn.className = btn.className.replace('bg-yellow-600', 'bg-green-600');
|
||||
if (card) { card.style.opacity = '0.4'; }
|
||||
m.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#d1fae5;border:1px solid #10b981;border-radius:6px;margin-top:8px;">' +
|
||||
'<i class="fas fa-check-circle text-green-600"></i>' +
|
||||
'<span style="color:#065f46;font-weight:500;">Протокол удалён. Клиенты: ' + (data.clients_removed || 0) + '</span></div>';
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} else {
|
||||
m.textContent = (data && data.error) ? data.error : ('Ошибка удаления (' + resp.status + ')');
|
||||
// Error state
|
||||
btn.innerHTML = '<i class="fas fa-times mr-1"></i>Ошибка';
|
||||
btn.className = origClasses;
|
||||
if (card) { card.style.opacity = '1'; const ov = document.getElementById('uninstall-overlay-' + slug); if (ov) ov.remove(); }
|
||||
m.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fee2e2;border:1px solid #ef4444;border-radius:6px;margin-top:8px;">' +
|
||||
'<i class="fas fa-exclamation-circle text-red-600"></i>' +
|
||||
'<span style="color:#991b1b;">' + ((data && data.error) ? data.error : ('Ошибка (' + resp.status + ')')) + '</span></div>';
|
||||
setTimeout(() => { btn.innerHTML = origHTML; document.querySelectorAll('.btn-uninstall-sp').forEach(b => { b.disabled = false; b.style.opacity = '1'; }); }, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
m.textContent = e.message || 'Ошибка связи';
|
||||
} catch (err) {
|
||||
btn.innerHTML = '<i class="fas fa-times mr-1"></i>Ошибка';
|
||||
btn.className = origClasses;
|
||||
if (card) { card.style.opacity = '1'; const ov = document.getElementById('uninstall-overlay-' + slug); if (ov) ov.remove(); }
|
||||
m.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:#fee2e2;border:1px solid #ef4444;border-radius:6px;margin-top:8px;">' +
|
||||
'<i class="fas fa-exclamation-circle text-red-600"></i>' +
|
||||
'<span style="color:#991b1b;">' + (err.message || 'Ошибка связи') + '</span></div>';
|
||||
setTimeout(() => { btn.innerHTML = origHTML; document.querySelectorAll('.btn-uninstall-sp').forEach(b => { b.disabled = false; b.style.opacity = '1'; }); }, 3000);
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -568,6 +681,92 @@ async function syncAllStats(serverId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── WARP Status Widget ──
|
||||
function updateWarpWidget(data) {
|
||||
const badge = document.getElementById('warpStatusBadge');
|
||||
const proxy = document.getElementById('warpProxy');
|
||||
const exitIp = document.getElementById('warpExitIp');
|
||||
const mode = document.getElementById('warpMode');
|
||||
const svc = document.getElementById('warpSvc');
|
||||
const btnConnect = document.getElementById('warpBtnConnect');
|
||||
const btnDisconnect = document.getElementById('warpBtnDisconnect');
|
||||
const btnReconnect = document.getElementById('warpBtnReconnect');
|
||||
|
||||
if (!badge) return;
|
||||
|
||||
if (!data.installed) {
|
||||
badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs bg-gray-600 text-gray-300">Не установлен</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.connected) {
|
||||
badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs text-white" style="background:#22c55e"><i class="fas fa-check-circle mr-1"></i>Connected</span>';
|
||||
} else {
|
||||
badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs text-white" style="background:#ef4444"><i class="fas fa-times-circle mr-1"></i>Disconnected</span>';
|
||||
}
|
||||
|
||||
if (proxy) proxy.textContent = data.proxy_listening ? ('socks5h://127.0.0.1:' + (data.proxy_port || 40000)) : '—';
|
||||
if (exitIp) exitIp.textContent = data.warp_ip || '—';
|
||||
if (mode) mode.textContent = data.mode || '—';
|
||||
if (svc) svc.textContent = data.service_status || '—';
|
||||
|
||||
if (btnConnect) { btnConnect.disabled = false; }
|
||||
if (btnDisconnect) { btnDisconnect.disabled = false; }
|
||||
if (btnReconnect) { btnReconnect.disabled = false; }
|
||||
}
|
||||
|
||||
async function loadWarpStatus() {
|
||||
const widget = document.getElementById('warpStatusWidget');
|
||||
if (!widget) return;
|
||||
try {
|
||||
const res = await fetch('/servers/{{ server.id }}/warp/status', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data.success !== false) {
|
||||
updateWarpWidget(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WARP status error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function warpAction(action) {
|
||||
const btnConnect = document.getElementById('warpBtnConnect');
|
||||
const btnDisconnect = document.getElementById('warpBtnDisconnect');
|
||||
const btnReconnect = document.getElementById('warpBtnReconnect');
|
||||
if (btnConnect) btnConnect.disabled = true;
|
||||
if (btnDisconnect) btnDisconnect.disabled = true;
|
||||
if (btnReconnect) btnReconnect.disabled = true;
|
||||
|
||||
const badge = document.getElementById('warpStatusBadge');
|
||||
if (badge) badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs bg-gray-600 text-gray-300"><i class="fas fa-spinner fa-spin mr-1"></i>' + action + '...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/servers/{{ server.id }}/warp/action', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: action })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success !== false) {
|
||||
updateWarpWidget(data);
|
||||
} else {
|
||||
if (badge) badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs bg-red-600 text-white">' + (data.error || 'Ошибка') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
if (badge) badge.innerHTML = '<span class="px-2 py-0.5 rounded text-xs bg-red-600 text-white">' + e.message + '</span>';
|
||||
}
|
||||
if (btnConnect) btnConnect.disabled = false;
|
||||
if (btnDisconnect) btnDisconnect.disabled = false;
|
||||
if (btnReconnect) btnReconnect.disabled = false;
|
||||
}
|
||||
|
||||
// WARP auto-refresh
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadWarpStatus();
|
||||
setInterval(loadWarpStatus, 30000);
|
||||
});
|
||||
|
||||
// Load backups on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadBackups({{ server.id }});
|
||||
|
||||
Reference in New Issue
Block a user