Compare commits

...

15 Commits

Author SHA1 Message Date
infosave baa3ef5f76 feat(awg2): add server obfuscation downgrade script for older router clients
Some router AmneziaWG implementations only support "classic" AmneziaWG 1.0
obfuscation and reject AWG 2.0 configs (range H1-H4, S3/S4, I1-I5 magic
packets) that the Amnezia app and newer servers use — the config imports/
handshakes fine on phones but fails on the router.

scripts/awg_downgrade_obfuscation.sh converts a server's wg0/awg0.conf to a
router-compatible classic set: keeps Jc/Jmin/Jmax/S1/S2, collapses H1-H4
ranges to single values, drops S3/S4 and I1-I5, then reloads the interface
(auto-detecting awg/wg). After running it, regenerate client configs in the
panel (which mirrors the server's params) and re-import on all devices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:17:47 +03:00
infosave 24a6cb276f fix(awg2): clamp TCP MSS on server so traffic actually flows (issue #50)
Final piece of "connects but no traffic": with the reduced client MTU (1280)
the upload direction fits, but full-size download packets (web pages, TLS
responses) still exceeded the AmneziaWG tunnel and were dropped — handshake
and small packets worked, browsing stalled. Confirmed on a live server: the
client's encrypted packets reached the server but large return packets never
made it back. Adding a server-side TCP MSS clamp to 1240 (= 1280 - 40) made
real traffic flow (verified: 1.6 MiB transferred, FORWARD/MASQUERADE counters
incrementing).

- VpnClient::addClientToServer(): after applying the peer, idempotently ensure
  net.ipv4.ip_forward=1 and a `mangle FORWARD ... TCPMSS --set-mss 1240` rule
  (-C then -A). Re-applied on every client creation, so it survives container
  restarts/reinstalls and covers adopted native Amnezia containers.
- migrations/072 + 064: add the same MSS clamp to the awg2 install script
  PostUp (and remove it in PostDown) for panel-installed servers.

Verified end-to-end: removing the rule and creating a client via the panel
re-adds it automatically; the live phone client now browses normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:33:11 +03:00
infosave 222953049d fix(awg2): restore client MTU=1280 (connects but no traffic)
Issue #50: AWG2 clients connect (handshake OK) but no traffic flows. 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: small packets (the
handshake) pass, larger packets (TLS, web pages) are dropped — tunnel
"connected" but unusable. 1280 is the official Amnezia app default.

- migrations/071: add "MTU = 1280" to the awg2 output_template (existing DBs).
- migrations/064: add the MTU line to the template source (fresh installs).
- buildClientConfig(): emit MTU = 1280 in the fallback path too.

Server-side NAT/forwarding/ip_forward were verified correct on a live server,
so this is purely a client-config regression. Generated client config now
contains "MTU = 1280" and mirrors the server's obfuscation params exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:23:41 +03:00
infosave d771af866c fix(awg2): mirror server obfuscation params in client config (fixes no-connect)
Clients were created successfully but could not connect: the AmneziaWG
handshake requires the client's obfuscation params (Jc/Jmin/Jmax/S1-S4/
H1-H4/I1-I5) to EXACTLY match the server's, and they did not.

Two causes, both fixed:
- syncServerKeysFromContainer() read params from `wg show` first and only
  accepted H1-H4 in the AWG-2.0 "a-b" range format, dropping the single-value
  H1-H4 used by classic AmneziaWG servers (the official Amnezia image). It
  also skipped the complete wg0.conf read once `wg show` returned partial
  data. Now the server config file (awg0.conf/wg0.conf) is the primary,
  format-agnostic source; `wg show` is a fallback that accepts single values
  and ranges.
- create() filled any param missing from the (incomplete) sync with awg2
  defaults — injecting H1-H4 ranges, S3/S4 and I1 onto a classic server that
  uses none of them. Now client params mirror the server's synced params
  verbatim; defaults are used only when nothing was synced at all. Empty
  AWG lines (params the server does not use) are stripped from the rendered
  config so the client carries exactly the server's set.

Verified end-to-end on a live server: a real amneziawg-go client built from
the generated config completes the handshake
("latest handshake: 14 seconds ago", bidirectional transfer) — params
(jc/s1/s2/h1-h4) match the server exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:41:24 +03:00
infosave 0d72579edd fix(awg2): auto-detect wg/awg tool inside container (real cause of issue #50)
Live testing against an AmneziaWG 2.0 server revealed the actual root cause
of "Failed to generate client keys": the official Amnezia container image
ships the userspace tool only as `wg` (a patched AmneziaWG binary) and has
NO `awg` binary, while the panel hardcoded `awg` for AWG2. `awg genkey` then
failed with "sh: awg: not found". (amneziawg-go ships `awg` with `wg`
symlinked, so both names work there — but the Amnezia image does not.)

- generateClientKeys(): detect the tool inside the container
  (`command -v awg || command -v wg`) instead of hardcoding `awg`.
- addClientToServer(): resolve the tool via new resolveWgTool() helper so
  `wg set` / `wg-quick up` (peer apply) also work on the Amnezia image.
- executeServerCommand(): delegate to VpnServer::executeCommand so SSH key
  auth + docker sudo auto-detection apply to all 19 call sites (it was
  password-only before).

Verified end-to-end on a live AWG2 server: pre-fix code fails with
"Failed to generate client keys: sh: awg: not found"; fixed code creates
the client, generates keys, and the peer appears in `wg show wg0`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:13:15 +03:00
infosave b819eb35b0 fix(awg2): resolve issue #50 client key generation and install timeout
Issue #50 (AmneziaWG 2.0 / awg2): "Failed to generate client keys" when
creating clients, and "Invalid server response" on first install.

- VpnClient::generateClientKeys() built its own password-only SSH command
  (PubkeyAuthentication=no, no sudo), bypassing VpnServer::executeCommand.
  That broke key-based servers and hosts where docker requires sudo. Route
  it through executeCommand so SSH-key auth and docker sudo auto-detection
  apply, matching every other remote operation.
- VpnClient::getNextClientIP() read /opt/amnezia/awg/wg0.conf only; AWG2
  uses awg0.conf. Read awg0.conf first, fall back to wg0.conf.
- deploy route: lift PHP time limit (set_time_limit(0) + ignore_user_abort)
  so the multi-minute awg2 docker build is not killed mid-request, which
  produced the truncated, non-JSON "Invalid server response".
- migration 070: drop `--no-cache` from the awg2 docker build so layers are
  reused, making installs and retries fast and idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:31:59 +03:00
infosave2007 809b0ca63d feat(migrations): Add WARP auto-integration with redsocks and iptables
- Implemented migration 067 to set up Cloudflare WARP with automatic routing for VPN client TCP traffic through a redsocks proxy.
- Included installation scripts for WARP and redsocks, along with iptables rules for traffic redirection.
- Added detection for X-Ray and patching of its outbound configuration.
- Created uninstall scripts to clean up configurations and remove installed packages.

fix(migrations): Enhance WARP install script for heredoc compatibility

- Implemented migration 068 to fix nested heredoc conflicts and streamline the WARP installation script for panel compatibility.
- Removed duplicate `set -eo pipefail` and adjusted formatting for better readability.

feat(migrations): Auto-detect AIVPN subnet for routing in WARP setup

- Implemented migration 069 to enhance the WARP installation script by adding detection for AIVPN subnets alongside existing AWG container detection.
- Updated routing logic to handle both container IPs and host-level VPN subnets.
- Ensured proper configuration of iptables for seamless traffic routing through the WARP proxy.
2026-04-25 10:40:21 +03:00
infosave2007 f04f9dd1cb refactor: enhance SSH command handling and auto-detect sudo requirements for Docker commands 2026-04-24 16:15:04 +03:00
infosave2007 aae920a5c2 fix: update SSH command execution to use semicolons for reliability and improve disk usage calculation 2026-04-24 15:48:42 +03:00
infosave2007 c9792a5d5d fix: update firebase/php-jwt dependency to version 7.0 issues/33 security advisories ("PKSA-y2cr-5h3j-g3ys") 2026-04-24 10:57:10 +03:00
infosave2007 63f3d202b6 fix: correct AIVPN byte counter mapping to match server-to-client and client-to-server traffic semantics 2026-04-24 07:16:09 +03:00
infosave2007 8eed687f66 refactor: consolidate metric collection into single SSH calls and add support for SSH key authentication 2026-04-24 07:07:57 +03:00
infosave2007 4c4b682256 fix: normalize SSH private key to prevent libcrypto errors caused by incorrect formatting 2026-04-24 06:48:18 +03:00
infosave2007 6c7bd421e3 refactor: migrate client management endpoints to web session auth and improve status validation 2026-04-24 06:44:08 +03:00
infosave2007 a8bb70a58f fix: prevent secondary protocols from overwriting primary server configuration data 2026-04-23 18:31:05 +03:00
23 changed files with 3907 additions and 371 deletions
+14
View File
@@ -31,6 +31,7 @@ Web-based management panel for Amnezia AWG (WireGuard) VPN servers.
- MTProxy (Telegram) (`mtproxy`) - MTProxy (Telegram) (`mtproxy`)
- SMB Server (`smb`) - SMB Server (`smb`)
- AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn - AIVPN (`aivpn`) - https://github.com/infosave2007/aivpn
- Cloudflare WARP Proxy (`cf-warp`) — transparent traffic proxying via Cloudflare
## Requirements ## Requirements
@@ -205,6 +206,19 @@ Manage VPN protocols via **Settings → Protocols**:
- Configure protocol settings (ports, transport, obfuscation) - Configure protocol settings (ports, transport, obfuscation)
- **AI Assistant**: Use "Ask AI" to generate complex protocol configurations tailored to your needs (requires OpenRouter API key). - **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 & Logs
**Scenario Testing**: **Scenario Testing**:
+409
View File
@@ -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
View File
@@ -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_atY-m-d H:i:s 或 null
POST /api/clients/{id}/extend - 延长客户端过期时间
参数:daysint
GET /api/clients/expiring - 获取即将过期的客户端
参数:days(默认:7)
POST /api/clients/{id}/set-traffic-limit - 设置客户端流量限制
参数:limit_bytesint 或 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_typewg-easy|3x-ui, backup_filemultipart/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
+224
View File
@@ -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
View File
@@ -25,15 +25,27 @@ ini_set('display_errors', 1);
ini_set('log_errors', 1); ini_set('log_errors', 1);
ini_set('error_log', '/var/log/metrics_collector_errors.log'); 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 // Write PID file for monitoring
$pidFile = '/var/run/collect_metrics.pid'; $pidFile = '/var/run/collect_metrics.pid';
file_put_contents($pidFile, getmypid()); file_put_contents($pidFile, getmypid());
// Register shutdown function to clean up PID file // Register shutdown function to clean up PID and lock files
register_shutdown_function(function() use ($pidFile) { register_shutdown_function(function() use ($pidFile, $lockFp, $lockFile) {
if (file_exists($pidFile)) { if (file_exists($pidFile)) {
unlink($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"; echo "[" . date('Y-m-d H:i:s') . "] Metrics collector started (PID: " . getmypid() . ")\n";
Regular → Executable
+15
View File
@@ -2,15 +2,24 @@
# Monitor and restart metrics collector if it's not running # Monitor and restart metrics collector if it's not running
# This script checks if collect_metrics.php is running and restarts it if needed # 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" SCRIPT_PATH="/var/www/html/bin/collect_metrics.php"
LOG_FILE="/var/log/metrics_monitor.log" LOG_FILE="/var/log/metrics_monitor.log"
PID_FILE="/var/run/collect_metrics.pid" PID_FILE="/var/run/collect_metrics.pid"
LOCK_FILE="/var/run/collect_metrics.lock"
log_message() { log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" 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 # Check if the process is running
is_running() { is_running() {
if [ -f "$PID_FILE" ]; then if [ -f "$PID_FILE" ]; then
@@ -22,6 +31,12 @@ is_running() {
fi fi
fi 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 return 1
} }
+1 -1
View File
@@ -12,7 +12,7 @@
"php": ">=8.0", "php": ">=8.0",
"twig/twig": "^3.8", "twig/twig": "^3.8",
"endroid/qr-code": "^4.8 || ^5.0", "endroid/qr-code": "^4.8 || ^5.0",
"firebase/php-jwt": "^6.11", "firebase/php-jwt": "^7.0",
"ext-pdo": "*", "ext-pdo": "*",
"ext-json": "*", "ext-json": "*",
"ext-curl": "*", "ext-curl": "*",
+721 -130
View File
@@ -289,24 +289,18 @@ class InstallProtocolManager
private static function detect(VpnServer $server, array $protocol, array $options = []): array private static function detect(VpnServer $server, array $protocol, array $options = []): array
{ {
$engine = self::getEngine($protocol); $handler = self::resolveHandler($protocol);
if ($engine === 'builtin_awg') {
return self::detectBuiltinAwg($server, $protocol); switch ($handler) {
case 'awg':
return self::detectBuiltinAwg($server, $protocol);
case 'xray':
return self::detectBuiltinXray($server, $protocol);
case 'warp':
return self::detectBuiltinWarp($server, $protocol);
default:
return self::runScript($server, $protocol, 'detect', $options);
} }
$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') {
return self::detectBuiltinXray($server, $protocol);
}
return self::runScript($server, $protocol, 'detect', $options);
} }
public static function install(VpnServer $server, array $protocol, array $options = []): array public static function install(VpnServer $server, array $protocol, array $options = []): array
@@ -397,30 +391,22 @@ class InstallProtocolManager
private static function restore(VpnServer $server, array $protocol, array $detection, array $options = []): array private static function restore(VpnServer $server, array $protocol, array $detection, array $options = []): array
{ {
$engine = self::getEngine($protocol); $handler = self::resolveHandler($protocol);
if ($engine === 'builtin_awg') {
return self::restoreBuiltinAwg($server, $protocol, $detection, $options);
}
$slug = $protocol['slug'] ?? ''; switch ($handler) {
case 'awg':
// For AWG shell-based scenarios, use builtin AWG restore return self::restoreBuiltinAwg($server, $protocol, $detection, $options);
if (self::isAwgProtocol($slug, $protocol)) { case 'xray':
return self::restoreBuiltinAwg($server, $protocol, $detection, $options); return self::restoreBuiltinXray($server, $protocol, $detection, $options);
default:
$result = self::runScript($server, $protocol, 'restore', array_merge($options, [
'detection' => $detection
]));
if (!isset($result['success'])) {
$result['success'] = true;
}
return $result;
} }
// For X-Ray VLESS, use builtin restore
if ($slug === 'xray-vless') {
return self::restoreBuiltinXray($server, $protocol, $detection, $options);
}
$result = self::runScript($server, $protocol, 'restore', array_merge($options, [
'detection' => $detection
]));
if (!isset($result['success'])) {
$result['success'] = true;
}
return $result;
} }
private static function detectBuiltinAwg(VpnServer $server, array $protocol): array private static function detectBuiltinAwg(VpnServer $server, array $protocol): array
@@ -1245,29 +1231,77 @@ class InstallProtocolManager
return $row; 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'] ?? []; $slug = $protocol['slug'] ?? '';
if (!empty($protocol['install_script'])) {
return 'shell'; // ── 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) * Legacy compatibility: get engine string
* Used to route shell-based AWG scenarios to builtin AWG detection/restore
*/ */
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)) { $handler = self::resolveHandler($protocol);
return true; if ($handler === 'awg') return 'builtin_awg';
} return 'shell';
$installScript = (string) ($protocol['install_script'] ?? '');
if ($installScript !== '' && preg_match('/amneziavpn\/amnezia-wg|amnezia\/awg|amnezia-awg/i', $installScript)) {
return true;
}
return false;
} }
private static function fallbackProtocols(): array private static function fallbackProtocols(): array
@@ -1338,24 +1372,18 @@ class InstallProtocolManager
*/ */
public static function runDetection(VpnServer $server, array $protocol, array $options = []): array public static function runDetection(VpnServer $server, array $protocol, array $options = []): array
{ {
$engine = self::getEngine($protocol); $handler = self::resolveHandler($protocol);
if ($engine === 'builtin_awg') {
return self::detectBuiltinAwg($server, $protocol); switch ($handler) {
case 'awg':
return self::detectBuiltinAwg($server, $protocol);
case 'xray':
return self::detectBuiltinXray($server, $protocol);
case 'warp':
return self::detectBuiltinWarp($server, $protocol);
default:
return self::runScript($server, $protocol, 'detect', $options);
} }
$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') {
return self::detectBuiltinXray($server, $protocol);
}
return self::runScript($server, $protocol, 'detect', $options);
} }
/** /**
@@ -1364,27 +1392,29 @@ class InstallProtocolManager
*/ */
public static function uninstall(VpnServer $server, array $protocol, array $options = []): array public static function uninstall(VpnServer $server, array $protocol, array $options = []): array
{ {
$engine = self::getEngine($protocol); $slug = $protocol['slug'] ?? 'unknown';
if ($engine === 'builtin_awg') { $handler = self::resolveHandler($protocol);
return self::uninstallBuiltinAwg($server, $protocol, $options); Logger::appendInstall($server->getId(), 'UNINSTALL: slug=' . $slug . ' handler=' . $handler);
}
// For script-driven protocols, try to detect AWG scenario and fallback to builtin uninstall switch ($handler) {
$slug = $protocol['slug'] ?? ''; case 'warp':
if (self::isAwgProtocol($slug, $protocol)) { return self::uninstallBuiltinWarp($server, $protocol, $options);
// Prefer builtin AWG uninstall by default because script variants may have CRLF issues
// or leave behind the canonical container name, causing install conflicts. case 'awg':
if (!empty($options['use_script_uninstall'])) { // Prefer builtin AWG uninstall; script variant only on explicit request
$hasScript = isset($protocol['uninstall_script']) && trim((string) $protocol['uninstall_script']) !== ''; if (!empty($options['use_script_uninstall'])) {
if ($hasScript) { $hasScript = isset($protocol['uninstall_script']) && trim((string) $protocol['uninstall_script']) !== '';
return self::runScript($server, $protocol, 'uninstall', $options); if ($hasScript) {
return self::runScript($server, $protocol, 'uninstall', $options);
}
} }
} return self::uninstallBuiltinAwg($server, $protocol, $options);
return self::uninstallBuiltinAwg($server, $protocol, $options);
}
// For other script-driven protocols, look for an "uninstall" phase in scripts case 'xray':
return self::runScript($server, $protocol, 'uninstall', $options); case 'script':
default:
return self::runScript($server, $protocol, 'uninstall', $options);
}
} }
private static function uninstallBuiltinAwg(VpnServer $server, array $protocol, array $options = []): array 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 ── // ── Check for existing installation before doing anything destructive ──
$slug = $protocol['slug'] ?? ''; $slug = $protocol['slug'] ?? '';
$isAwg = $engine === 'builtin_awg' || self::isAwgProtocol($slug, $protocol); $handler = self::resolveHandler($protocol);
$isXray = $slug === 'xray-vless'; $isAwg = $handler === 'awg';
$isXray = $handler === 'xray';
if ($isAwg) { if ($isAwg) {
$detection = self::detectBuiltinAwg($server, $protocol); $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 ── // ── No existing installation found — proceed with fresh install ──
if ($engine === 'builtin_awg') { if ($engine === 'builtin_awg') {
@@ -1517,13 +1559,21 @@ class InstallProtocolManager
} }
} }
self::markServerActive($serverId, null, [ $existingProtocol = $server->getData()['install_protocol'] ?? '';
'vpn_port' => $resolvedPort, $currentSlug = $protocol['slug'] ?? '';
'server_public_key' => $res['server_public_key'] ?? null, $isFirstProtocol = ($existingProtocol === '' || $existingProtocol === $currentSlug);
'preshared_key' => $res['preshared_key'] ?? null, if ($isFirstProtocol) {
'container_name' => $res['container_name'] ?? null, self::markServerActive($serverId, null, [
'awg_params' => $resolvedAwgParams, 'vpn_port' => $resolvedPort,
]); 'server_public_key' => $res['server_public_key'] ?? null,
'preshared_key' => $res['preshared_key'] ?? null,
'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(); $pdo = DB::conn();
$pid = self::resolveProtocolId($protocol); $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 = $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)]); $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) { if ($port !== null && $port > 0) {
self::markServerActive($serverId, null, ['vpn_port' => $port]); $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; return $res;
} catch (Throwable $e) { } catch (Throwable $e) {
$message = (string) $e->getMessage(); $message = (string) $e->getMessage();
@@ -1723,10 +1785,11 @@ class InstallProtocolManager
$binaryCmd = '/usr/local/bin/aivpn-server'; $binaryCmd = '/usr/local/bin/aivpn-server';
// Verify the binary exists, fallback to other locations if needed // 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"', $checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
escapeshellarg($containerName), escapeshellarg($containerName),
escapeshellarg($binaryCmd)); escapeshellarg($binaryCmd));
$checkResult = (string) $server->executeCommand($checkCmd, true); $checkResult = (string) $server->executeCommand($checkCmd, null);
if (strpos($checkResult, 'found') === false) { if (strpos($checkResult, 'found') === false) {
// Try alternative locations // Try alternative locations
$fallbacks = [ $fallbacks = [
@@ -1739,7 +1802,7 @@ class InstallProtocolManager
$checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"', $checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
escapeshellarg($containerName), escapeshellarg($containerName),
escapeshellarg($loc)); escapeshellarg($loc));
$checkResult = (string) $server->executeCommand($checkCmd, true); $checkResult = (string) $server->executeCommand($checkCmd, null);
if (strpos($checkResult, 'found') !== false) { if (strpos($checkResult, 'found') !== false) {
$binaryCmd = $loc; $binaryCmd = $loc;
break; break;
@@ -1770,38 +1833,50 @@ class InstallProtocolManager
Logger::appendInstall($server->getId(), 'Adding AIVPN client via builtin add_client: ' . $clientName . ' in ' . $containerName); Logger::appendInstall($server->getId(), 'Adding AIVPN client via builtin add_client: ' . $clientName . ' in ' . $containerName);
try { try {
$output = (string) $server->executeCommand($cmd, true); // Use auto-detection for sudo requirement (null = auto-detect for docker commands)
} catch (Exception $e) { $output = (string) $server->executeCommand($cmd, null);
// Container may be restarting or unavailable - try host binary fallback } catch (Exception $e) {
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); $hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
if ($hostResult !== null) { if ($hostResult !== null) {
return $hostResult; return $hostResult;
} }
return ['success' => true, 'connection_key' => '', 'connection_uri' => '']; return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
} }
// Check if docker exec returned an error (container not running, etc.) $trimmedOutput = trim($output);
$trimmedOutput = trim($output); if ($trimmedOutput === '' ||
if ($trimmedOutput === '' || stripos($trimmedOutput, 'Error response from daemon') !== false ||
stripos($trimmedOutput, 'Error response from daemon') !== false || stripos($trimmedOutput, 'is restarting') !== false ||
stripos($trimmedOutput, 'is restarting') !== false || stripos($trimmedOutput, 'No such container') !== false ||
stripos($trimmedOutput, 'No such container') !== false || stripos($trimmedOutput, 'executable file not found') !== false) {
stripos($trimmedOutput, 'executable file not found') !== false) { Logger::appendInstall($server->getId(), 'AIVPN add_client container unavailable, trying host binary fallback');
// Container unavailable - try host binary fallback $hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
Logger::appendInstall($server->getId(), 'AIVPN add_client container unavailable, trying host binary fallback'); if ($hostResult !== null) {
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort); return $hostResult;
if ($hostResult !== null) { }
return $hostResult; return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
} }
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); $parsed = self::parseAivpnAddClientOutput($output);
if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) { if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) {
$head = substr(str_replace(["\r", "\n"], ' ', $trimmedOutput), 0, 220); $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]; $result = ['success' => true];
@@ -2580,4 +2655,520 @@ class InstallProtocolManager
Logger::appendInstall($serverId, "AWG client import complete: imported {$imported} clients"); 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
}
}
} }
+168 -143
View File
@@ -83,17 +83,121 @@ class ServerMonitoring
/** /**
* Collect all server metrics * Collect all server metrics
* Uses a single SSH call to minimize connections (#42)
*/ */
public function collectMetrics(): array 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 = [ $metrics = [
'cpu_percent' => $this->getCpuUsage(), 'cpu_percent' => $cpu,
'ram_used_mb' => $this->getRamUsed(), 'ram_used_mb' => $ramUsed,
'ram_total_mb' => $this->getRamTotal(), 'ram_total_mb' => $ramTotal,
'disk_used_gb' => $this->getDiskUsed(), 'disk_used_gb' => $diskUsed,
'disk_total_gb' => $this->getDiskTotal(), 'disk_total_gb' => $diskTotal,
'network_rx_mbps' => $this->getNetworkRxSpeed(), 'network_rx_mbps' => $rxMbps,
'network_tx_mbps' => $this->getNetworkTxSpeed(), 'network_tx_mbps' => $txMbps,
]; ];
$this->saveServerMetrics($metrics); $this->saveServerMetrics($metrics);
@@ -116,11 +220,10 @@ class ServerMonitoring
} }
// Pre-fetch X-ray stats only for Xray servers. // 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->isXrayServer()) {
if (!$this->fetchXrayStats()) { if (!$this->fetchXrayStats()) {
error_log("Failed to fetch X-ray stats, preventing DB overwrite"); 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); $stats = $this->getClientStats($client);
if ($stats) { 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); $this->saveClientMetrics($client['id'], $stats);
$results[] = [ $results[] = [
'client_id' => $client['id'], 'client_id' => $client['id'],
@@ -159,113 +256,6 @@ class ServerMonitoring
return $results; 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 * Get client current stats and calculate speed
*/ */
@@ -378,18 +368,20 @@ class ServerMonitoring
$rawOutNow = (int) ($aivpn['bytes_out'] ?? 0); $rawOutNow = (int) ($aivpn['bytes_out'] ?? 0);
// Detect counter rollover/reset in AIVPN source and preserve cumulative totals. // Detect counter rollover/reset in AIVPN source and preserve cumulative totals.
// bytes_in -> received (download), bytes_out -> sent (upload)
if ($rawInNow < $rawInPrev) { if ($rawInNow < $rawInPrev) {
$offsetIn = max($offsetIn + $rawInPrev, $prevSent); $offsetIn = max($offsetIn + $rawInPrev, $prevReceived);
} }
if ($rawOutNow < $rawOutPrev) { if ($rawOutNow < $rawOutPrev) {
$offsetOut = max($offsetOut + $rawOutPrev, $prevReceived); $offsetOut = max($offsetOut + $rawOutPrev, $prevSent);
} }
$candidateSent = $offsetIn + $rawInNow; $candidateReceived = $offsetIn + $rawInNow;
$candidateReceived = $offsetOut + $rawOutNow; $candidateSent = $offsetOut + $rawOutNow;
// AIVPN stores per-client counters as bytes_in/bytes_out. // AIVPN bytes_in = data downloaded BY client (server→client)
// Map to panel semantics: sent=client upload, received=client download. // AIVPN bytes_out = data uploaded BY client (client→server)
// Verified via `aivpn-server --list-clients` where bytes_in = DOWNLOAD column
$bytesSent = max($prevSent, $candidateSent); $bytesSent = max($prevSent, $candidateSent);
$bytesReceived = max($prevReceived, $candidateReceived); $bytesReceived = max($prevReceived, $candidateReceived);
@@ -629,27 +621,60 @@ class ServerMonitoring
/** /**
* Execute SSH command on server * Execute SSH command on server
* Supports both password and SSH key authentication
*/ */
private function execSSH(string $cmd): ?string private function execSSH(string $cmd): ?string
{ {
$host = $this->serverData['host']; $host = $this->serverData['host'];
$port = (int)$this->serverData['port']; $port = (int)$this->serverData['port'];
$username = $this->serverData['username']; $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';
$sshCmd = sprintf( $keyFile = '';
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
$password, if (!empty($sshKey)) {
$port, // SSH key authentication
$sshOptions, $keyFile = tempnam(sys_get_temp_dir(), 'sshkey');
$username, // Normalize key (fix \r\n, ensure trailing newline)
$host, $sshKey = str_replace("\r\n", "\n", $sshKey);
escapeshellarg($cmd) $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(
"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); $output = shell_exec($sshCmd);
// Clean up temp key file
if ($keyFile && file_exists($keyFile)) {
unlink($keyFile);
}
return $output ?: null; return $output ?: null;
} }
@@ -1093,8 +1118,8 @@ class ServerMonitoring
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5'; $sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
$sshCmd = sprintf( $sshCmd = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null", "sshpass -p %s ssh -p %d %s %s@%s %s 2>/dev/null",
$password, escapeshellarg($password),
$port, $port,
$sshOptions, $sshOptions,
$username, $username,
@@ -1175,8 +1200,8 @@ class ServerMonitoring
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5'; $sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
$sshCmd = sprintf( $sshCmd = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null", "sshpass -p %s ssh -p %d %s %s@%s %s 2>/dev/null",
$password, escapeshellarg($password),
$port, $port,
$sshOptions, $sshOptions,
$username, $username,
+134 -54
View File
@@ -199,12 +199,22 @@ class VpnClient
$defaultAwgParams = self::getAwgParamDefaults($slug); $defaultAwgParams = self::getAwgParamDefaults($slug);
// Add AWG parameters (use UPPERCASE keys internal logic) // AmneziaWG requires the client's obfuscation params to EXACTLY match
foreach (array_keys($defaultAwgParams) as $key) { // the server's. When the server's params are known (synced from its
if (isset($cleanAwgParams[$key])) { // config), mirror them verbatim and leave anything the server does not
$vars[$key] = $cleanAwgParams[$key]; // use empty (those lines are stripped from the rendered config below).
} else { // Only fall back to protocol defaults when NO params were synced at all
$vars[$key] = $defaultAwgParams[$key]; // (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 {
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); self::addClientToServer($serverData, $keys['public'], $clientIP);
$qrCode = self::generateQRCode($config, $slug); $qrCode = self::generateQRCode($config, $slug);
$priv = $keys['private']; $priv = $keys['private'];
@@ -797,28 +819,36 @@ class VpnClient
private static function generateClientKeys(array $serverData, string $clientName): array private static function generateClientKeys(array $serverData, string $clientName): array
{ {
$containerName = $serverData['container_name']; $containerName = $serverData['container_name'];
$protocolSlug = (string) ($serverData['install_protocol'] ?? '');
$isAwg2 = (stripos($containerName, 'awg2') !== false || $protocolSlug === 'awg2'); // Detect the WireGuard userspace tool INSIDE the container instead of
$wgTool = $isAwg2 ? 'awg' : 'wg'; // 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( $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), escapeshellarg($containerName),
$wgTool, escapeshellarg($script)
$wgTool
); );
$escaped = escapeshellarg($cmd); // Route the command through VpnServer::executeCommand so that SSH key
$sshCmd = sprintf( // authentication and automatic docker sudo detection are handled the same
"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", // way as every other remote operation. The previous implementation built
$serverData['password'], // its own password-only SSH command (PubkeyAuthentication=no, no sudo),
$serverData['port'], // which failed on key-based servers and on hosts where docker needs sudo,
$serverData['username'], // producing the "Failed to generate client keys" error (issue #50).
$serverData['host'], $server = new VpnServer((int) $serverData['id']);
$escaped $out = (string) $server->executeCommand($cmd); // null sudo => auto-detect for docker
);
$out = (string) shell_exec($sshCmd);
$parts = explode("---", trim($out)); $parts = explode("---", trim($out));
if (count($parts) < 2) { if (count($parts) < 2) {
@@ -860,8 +890,11 @@ class VpnClient
try { try {
$containerName = $serverData['container_name'] ?? 'amnezia-awg'; $containerName = $serverData['container_name'] ?? 'amnezia-awg';
$server = new VpnServer($serverData['id']); $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( $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) escapeshellarg($containerName)
); );
$serverConfig = $server->executeCommand($cmd, true); $serverConfig = $server->executeCommand($cmd, true);
@@ -1053,37 +1086,32 @@ class VpnClient
$dns = '1.1.1.1, 1.0.0.1'; $dns = '1.1.1.1, 1.0.0.1';
} }
// Extract AWG parameters. // Extract AWG obfuscation parameters. The server config file is the
// NOTE: amnezia-awg does not expose these via `wg show` in many builds, // source of truth: it holds the EXACT params awg-quick applied, in the
// so we primarily read them from /opt/amnezia/awg/wg0.conf. // 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 = []; $awgParams = [];
foreach (['/opt/amnezia/awg/awg0.conf', '/opt/amnezia/awg/wg0.conf', '/etc/wireguard/wg0.conf'] as $confPath) {
// Legacy attempt: some builds print jc/jmin/... in `wg show` output. $awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $confPath);
$wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null"; if (!empty($awgParams)) {
$wgOutput = (string) $server->executeCommand($wgShowCmd, true); break;
$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];
}
} else {
if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) {
$awgParams[strtoupper($param)] = (int) $matches[1];
}
} }
} }
// Primary source: wg0.conf // 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)) { if (empty($awgParams)) {
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, $primaryConfigDir . '/wg0.conf'); $wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null";
if (empty($awgParams) && $primaryConfigDir !== '/opt/amnezia/awg') { $wgOutput = (string) $server->executeCommand($wgShowCmd, true);
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/opt/amnezia/awg/wg0.conf'); 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)) {
if (empty($awgParams)) { $val = $matches[1];
$awgParams = self::extractAwgParamsFromWg0Conf($server, $containerName, '/etc/wireguard/wg0.conf'); $awgParams[strtoupper($param)] = ctype_digit($val) ? (int) $val : $val;
}
} }
} }
@@ -1154,6 +1182,10 @@ class VpnClient
$config .= "Address = {$clientIP}/32\n"; $config .= "Address = {$clientIP}/32\n";
$config .= "DNS = 1.1.1.1, 1.0.0.1\n"; $config .= "DNS = 1.1.1.1, 1.0.0.1\n";
$config .= "PrivateKey = {$privateKey}\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) // 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 // 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'); throw new Exception('Refusing to add client with empty public key');
} }
// Determine correct tool names (awg for AWG2, wg for standard) // Determine correct tool names by probing the container. The official
$wgTool = $isAwg2 ? 'awg' : 'wg'; // Amnezia image exposes only `wg`/`wg-quick`; amneziawg-go provides
$wgQuickTool = $isAwg2 ? 'awg-quick' : 'wg-quick'; // `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) // 1. Create temp file for PSK (to avoid shell escaping issues)
$pskFile = '/tmp/' . bin2hex(random_bytes(8)) . '.psk'; $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 // 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); $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); 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); 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 * Execute command on server
*/ */
private static function executeServerCommand(array $serverData, string $command, bool $sudo = false): string 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'; $needsSudo = $sudo && strtolower((string) ($serverData['username'] ?? '')) !== 'root';
$baseCommand = $command; $baseCommand = $command;
@@ -1308,8 +1388,8 @@ class VpnClient
$run = static function (string $cmd) use ($serverData): string { $run = static function (string $cmd) use ($serverData): string {
$escapedCommand = escapeshellarg($cmd); $escapedCommand = escapeshellarg($cmd);
$sshCommand = sprintf( $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", "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'], escapeshellarg($serverData['password']),
$serverData['port'], $serverData['port'],
$serverData['username'], $serverData['username'],
$serverData['host'], $serverData['host'],
+131 -13
View File
@@ -8,6 +8,13 @@ class VpnServer
{ {
private $serverId; private $serverId;
private $data; 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) public function __construct(?int $serverId = null)
{ {
@@ -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 * Create new VPN server in database
*/ */
@@ -88,7 +118,7 @@ class VpnServer
$data['port'], $data['port'],
$data['username'], $data['username'],
$data['password'] ?? null, $data['password'] ?? null,
$data['ssh_key'] ?? null, !empty($data['ssh_key']) ? self::normalizeSshKey($data['ssh_key']) : null,
$data['container_name'] ?? 'amnezia-awg', $data['container_name'] ?? 'amnezia-awg',
$protocolSlug, $protocolSlug,
$installOptions, $installOptions,
@@ -388,7 +418,7 @@ class VpnServer
if (!empty($this->data['ssh_key'])) { if (!empty($this->data['ssh_key'])) {
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey'); $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); chmod($keyFile, 0600);
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
// sshpass is not needed for key-based auth // sshpass is not needed for key-based auth
@@ -404,8 +434,8 @@ class VpnServer
} else { } else {
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
$testCommand = sprintf( $testCommand = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s 'echo test' 2>/dev/null", "sshpass -p %s ssh -p %d %s %s@%s 'echo test' 2>/dev/null",
$this->data['password'], escapeshellarg($this->data['password']),
$this->data['port'], $this->data['port'],
$sshOptions, $sshOptions,
$this->data['username'], $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; $baseCommand = $command;
$pathPrefix = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH; '; $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 = ''; $escapedCommand = '';
$needsSudo = false; $needsSudo = false;
@@ -438,7 +479,7 @@ class VpnServer
if (!empty($this->data['ssh_key'])) { if (!empty($this->data['ssh_key'])) {
$keyFile = tempnam(sys_get_temp_dir(), 'sshkey'); $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); chmod($keyFile, 0600);
$sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey"; $sshOptions .= " -i {$keyFile} -o IdentitiesOnly=yes -o PubkeyAuthentication=yes -o PreferredAuthentications=publickey";
@@ -454,7 +495,7 @@ class VpnServer
$escapedCommand $escapedCommand
); );
} else { } else {
$needsSudo = $sudo && strtolower((string) ($this->data['username'] ?? '')) !== 'root'; $needsSudo = ($sudo ?? false) && strtolower((string) ($this->data['username'] ?? '')) !== 'root';
if ($needsSudo) { if ($needsSudo) {
// Suppress sudo prompt text to keep command output machine-parseable. // Suppress sudo prompt text to keep command output machine-parseable.
$command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command; $command = "echo '{$this->data['password']}' | sudo -S -p '' " . $command;
@@ -465,8 +506,8 @@ class VpnServer
$sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no"; $sshOptions .= " -o PreferredAuthentications=password -o PubkeyAuthentication=no";
$sshCommand = sprintf( $sshCommand = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", "sshpass -p %s ssh -p %d %s %s@%s %s 2>&1",
$this->data['password'], escapeshellarg($this->data['password']),
$this->data['port'], $this->data['port'],
$sshOptions, $sshOptions,
$this->data['username'], $this->data['username'],
@@ -481,13 +522,18 @@ class VpnServer
if ( if (
empty($this->data['ssh_key']) empty($this->data['ssh_key'])
&& !empty($needsSudo) && !empty($needsSudo)
&& preg_match('/(^|\\n)docker(\\s|$)/', ltrim($baseCommand)) && $isDockerCommand
&& preg_match('/incorrect password attempts|sorry, try again|a password is required/i', $output) && 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); $escapedBaseCommand = escapeshellarg($pathPrefix . $baseCommand);
$sshCommandNoSudo = sprintf( $sshCommandNoSudo = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>&1", "sshpass -p %s ssh -p %d %s %s@%s %s 2>&1",
$this->data['password'], escapeshellarg($this->data['password']),
$this->data['port'], $this->data['port'],
$sshOptions, $sshOptions,
$this->data['username'], $this->data['username'],
@@ -504,6 +550,78 @@ class VpnServer
return $output; 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 * Install Docker on remote server
*/ */
@@ -5,6 +5,7 @@ SET output_template = '[Interface]
Address = {{client_ip}}/32 Address = {{client_ip}}/32
DNS = {{dns_servers}} DNS = {{dns_servers}}
PrivateKey = {{private_key}} PrivateKey = {{private_key}}
MTU = 1280
Jc = {{Jc}} Jc = {{Jc}}
Jmin = {{Jmin}} Jmin = {{Jmin}}
Jmax = {{Jmax}} Jmax = {{Jmax}}
@@ -144,8 +145,8 @@ echo "H2 = $H2_VAL"
echo "H3 = $H3_VAL" echo "H3 = $H3_VAL"
echo "H4 = $H4_VAL" echo "H4 = $H4_VAL"
echo "I1 = $I1_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 "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" 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 } > /opt/amnezia/awg2/awg0.conf
echo "$PRIVATE_KEY" > /opt/amnezia/awg2/wireguard_server_private_key.key 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');
+246
View File
@@ -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';
+237
View File
@@ -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%';
+23
View File
@@ -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%';
+25
View File
@@ -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%';
+131
View File
@@ -568,6 +568,13 @@ Router::post('/servers/{id}/deploy', function ($params) {
requireAuth(); requireAuth();
header('Content-Type: application/json'); 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']; $serverId = (int) $params['id'];
$rawBody = file_get_contents('php://input'); $rawBody = file_get_contents('php://input');
$options = []; $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 // View server
Router::get('/servers/{id}', function ($params) { Router::get('/servers/{id}', function ($params) {
requireAuth(); 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 // Sync all stats for server
Router::post('/servers/{id}/sync-stats', function ($params) { Router::post('/servers/{id}/sync-stats', function ($params) {
requireAuth(); requireAuth();
+59
View File
@@ -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."
+5 -5
View File
@@ -239,7 +239,7 @@ async function updateExpiration(event, clientId) {
} }
try { try {
const response = await fetch(`/api/clients/${clientId}/set-expiration`, { const response = await fetch(`/clients/${clientId}/set-expiration`, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
@@ -250,7 +250,7 @@ async function updateExpiration(event, clientId) {
const data = await response.json(); const data = await response.json();
if (data.success !== false) { if (response.ok && data.success === true) {
alert('Expiration updated successfully'); alert('Expiration updated successfully');
document.getElementById('currentExpiration').textContent = displayText; document.getElementById('currentExpiration').textContent = displayText;
// Reset form // Reset form
@@ -293,18 +293,18 @@ async function updateTrafficLimit(event, clientId) {
} }
try { try {
const response = await fetch(`/api/clients/${clientId}/set-traffic-limit`, { const response = await fetch(`/clients/${clientId}/set-traffic-limit`, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ limit_bytes: limitBytes }) body: JSON.stringify({ traffic_limit: limitBytes })
}); });
const data = await response.json(); const data = await response.json();
if (data.success !== false) { if (response.ok && data.success === true) {
alert('Traffic limit updated successfully'); alert('Traffic limit updated successfully');
// Reload page to show updated traffic info // Reload page to show updated traffic info
window.location.reload(); window.location.reload();
+220 -21
View File
@@ -98,6 +98,59 @@
{% if sp.server_host %}<span>Host: {{ sp.server_host }}</span>{% endif %} {% 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 %} {% if sp.server_port %}<span class="ml-2">Port: {{ sp.server_port }}</span>{% endif %}
</div> </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> </div>
{% else %} {% else %}
<div class="text-sm text-gray-500">Нет установленных протоколов</div> <div class="text-sm text-gray-500">Нет установленных протоколов</div>
@@ -412,29 +465,42 @@ document.addEventListener('DOMContentLoaded', function() {
if (uninstallAllBtn) { if (uninstallAllBtn) {
uninstallAllBtn.addEventListener('click', async function(e) { uninstallAllBtn.addEventListener('click', async function(e) {
console.log('uninstallAllBtn clicked');
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) { if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) return;
console.log('User canceled');
return; const origHTML = uninstallAllBtn.innerHTML;
}
console.log('Starting uninstall all...');
uninstallAllBtn.disabled = true; 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 { try {
const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' }); const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' });
const data = await res.json(); const data = await res.json();
console.log('Response:', data);
if (data.success) { 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); setTimeout(() => location.reload(), 1200);
} else { } 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) { } catch (e) {
console.error('Error:', e); uninstallAllBtn.innerHTML = origHTML;
msg.textContent = e.message; 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; uninstallAllBtn.disabled = false;
}); });
@@ -481,24 +547,71 @@ document.addEventListener('DOMContentLoaded', function() {
if (!confirmed) return; if (!confirmed) return;
const slug = btn.getAttribute('data-slug'); const slug = btn.getAttribute('data-slug');
const m = document.getElementById('uninstallSpMsg'); const m = document.getElementById('uninstallSpMsg');
m.textContent = ''; const card = btn.closest('.border.rounded');
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>'; // 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 { try {
const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' }); const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' });
let data; let data;
const ct = resp.headers.get('content-type') || ''; const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; } if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; }
if (resp.ok && data && !data.error) { if (resp.ok && data && !data.error) {
m.textContent = 'Удалено. Клиенты: ' + (data.clients_removed || 0); // Success state
setTimeout(() => location.reload(), 800); 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 { } 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) { } catch (err) {
m.textContent = e.message || 'Ошибка связи'; 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 // Load backups on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadBackups({{ server.id }}); loadBackups({{ server.id }});