809b0ca63d
- 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.
3175 lines
141 KiB
PHP
3175 lines
141 KiB
PHP
<?php
|
||
require_once __DIR__ . '/Logger.php';
|
||
|
||
class InstallProtocolManager
|
||
{
|
||
private const DEFAULT_SLUG = 'amnezia-wg';
|
||
private const SESSION_KEY = 'pending_deploy_decisions';
|
||
|
||
private static function resolveAivpnContainerName(VpnServer $server, array $options = []): string
|
||
{
|
||
$serverData = $server->getData();
|
||
$candidates = array_values(array_unique(array_filter([
|
||
trim((string) ($options['container_name'] ?? '')),
|
||
trim((string) ($serverData['container_name'] ?? '')),
|
||
'aivpn-server',
|
||
], static function ($value) {
|
||
return is_string($value) && $value !== '';
|
||
})));
|
||
|
||
$namesRaw = (string) $server->executeCommand("docker ps -a --format '{{.Names}}' 2>/dev/null", true);
|
||
$names = array_values(array_filter(array_map('trim', preg_split('/\r?\n/', $namesRaw) ?: []), static function ($value) {
|
||
return $value !== '';
|
||
}));
|
||
|
||
foreach ($candidates as $candidate) {
|
||
if (in_array($candidate, $names, true)) {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
foreach ($candidates as $candidate) {
|
||
foreach ($names as $name) {
|
||
if (stripos($name, $candidate) !== false) {
|
||
self::persistServerContainerName($server->getId(), $name);
|
||
return $name;
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($names as $name) {
|
||
if (stripos($name, 'aivpn-server') !== false || stripos($name, 'aivpn') !== false) {
|
||
self::persistServerContainerName($server->getId(), $name);
|
||
return $name;
|
||
}
|
||
}
|
||
|
||
return $candidates[0] ?? 'aivpn-server';
|
||
}
|
||
|
||
private static function persistServerContainerName(int $serverId, string $containerName): void
|
||
{
|
||
$containerName = trim($containerName);
|
||
if ($serverId <= 0 || $containerName === '') {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('UPDATE vpn_servers SET container_name = ? WHERE id = ?');
|
||
$stmt->execute([$containerName, $serverId]);
|
||
} catch (Throwable $e) {
|
||
}
|
||
}
|
||
|
||
public static function getDefaultSlug(): string
|
||
{
|
||
return self::DEFAULT_SLUG;
|
||
}
|
||
|
||
public static function ensureDefaults(): void
|
||
{
|
||
return;
|
||
}
|
||
|
||
public static function listActive(): array
|
||
{
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->query('SELECT * FROM protocols WHERE is_active = 1 ORDER BY name');
|
||
$rows = $stmt->fetchAll();
|
||
return array_map([self::class, 'hydrateProtocol'], $rows);
|
||
} catch (Throwable $e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
public static function getAll(): array
|
||
{
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->query('SELECT * FROM protocols ORDER BY name');
|
||
$rows = $stmt->fetchAll();
|
||
return array_map([self::class, 'hydrateProtocol'], $rows);
|
||
} catch (Throwable $e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
public static function getBySlug(string $slug): ?array
|
||
{
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('SELECT * FROM protocols WHERE slug = ? LIMIT 1');
|
||
$stmt->execute([$slug]);
|
||
$row = $stmt->fetch();
|
||
if ($row) {
|
||
return self::hydrateProtocol($row);
|
||
}
|
||
} catch (Throwable $e) {
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public static function getById(int $id): ?array
|
||
{
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('SELECT * FROM protocols WHERE id = ? LIMIT 1');
|
||
$stmt->execute([$id]);
|
||
$row = $stmt->fetch();
|
||
return $row ? self::hydrateProtocol($row) : null;
|
||
} catch (Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public static function save(array $data): int
|
||
{
|
||
$pdo = DB::conn();
|
||
$definition = $data['definition'] ?? [];
|
||
if (is_string($definition)) {
|
||
$definition = json_decode($definition, true) ?: [];
|
||
}
|
||
|
||
$definitionJson = json_encode($definition, JSON_UNESCAPED_SLASHES);
|
||
$isActive = isset($data['is_active']) ? (int) $data['is_active'] : 1;
|
||
|
||
if (!empty($data['id'])) {
|
||
$stmt = $pdo->prepare('
|
||
UPDATE install_protocols
|
||
SET slug = ?, name = ?, description = ?, definition = ?, is_active = ?, updated_at = NOW()
|
||
WHERE id = ?
|
||
');
|
||
$stmt->execute([
|
||
$data['slug'],
|
||
$data['name'],
|
||
$data['description'] ?? null,
|
||
$definitionJson,
|
||
$isActive,
|
||
$data['id']
|
||
]);
|
||
return (int) $data['id'];
|
||
}
|
||
|
||
$stmt = $pdo->prepare('
|
||
INSERT INTO install_protocols (slug, name, description, definition, is_active)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
');
|
||
$stmt->execute([
|
||
$data['slug'],
|
||
$data['name'],
|
||
$data['description'] ?? null,
|
||
$definitionJson,
|
||
$isActive
|
||
]);
|
||
|
||
return (int) $pdo->lastInsertId();
|
||
}
|
||
|
||
public static function delete(int $id): void
|
||
{
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('DELETE FROM install_protocols WHERE id = ?');
|
||
$stmt->execute([$id]);
|
||
}
|
||
|
||
public static function deploy(VpnServer $server, array $options = []): array
|
||
{
|
||
$serverData = $server->getData();
|
||
$protocolSlug = $serverData['install_protocol'] ?? null;
|
||
if (!$protocolSlug || trim((string) $protocolSlug) === '') {
|
||
throw new Exception('Install protocol not selected');
|
||
}
|
||
$protocol = self::getBySlug($protocolSlug);
|
||
|
||
Logger::appendInstall($server->getId(), 'Deploy start for protocol ' . $protocolSlug);
|
||
|
||
try {
|
||
if (!$protocol) {
|
||
throw new Exception('Install protocol not found: ' . $protocolSlug);
|
||
}
|
||
|
||
$installMode = $options['install_mode'] ?? null;
|
||
$decisionToken = $options['decision_token'] ?? null;
|
||
$serverId = $server->getId();
|
||
$detectionPayload = null;
|
||
|
||
if (empty($options['skip_connection_test'])) {
|
||
if (!$server->testConnection()) {
|
||
Logger::appendInstall($serverId, 'SSH connection test failed');
|
||
throw new Exception('SSH connection failed');
|
||
}
|
||
Logger::appendInstall($serverId, 'SSH connection test OK');
|
||
}
|
||
|
||
if ($installMode !== null && $decisionToken) {
|
||
$entry = self::consumeDecision($serverId, $decisionToken);
|
||
if ($entry && ($entry['protocol'] ?? '') === $protocol['slug']) {
|
||
$detectionPayload = $entry['detection'] ?? null;
|
||
Logger::appendInstall($serverId, 'Consumed decision token for restore/reinstall');
|
||
}
|
||
}
|
||
|
||
if ($installMode === null) {
|
||
Logger::appendInstall($serverId, 'Running detection...');
|
||
$detection = self::detect($server, $protocol, $options);
|
||
Logger::appendInstall($serverId, 'Detection result: ' . json_encode($detection));
|
||
|
||
if (in_array($detection['status'] ?? 'absent', ['existing', 'partial'], true)) {
|
||
$token = self::storeDecision($serverId, [
|
||
'protocol' => $protocol['slug'],
|
||
'detection' => $detection,
|
||
'stored_at' => time(),
|
||
]);
|
||
|
||
Logger::appendInstall($serverId, 'Existing/partial config found, awaiting decision. token=' . $token);
|
||
|
||
return [
|
||
'success' => false,
|
||
'requires_action' => true,
|
||
'action' => 'existing_configuration',
|
||
'details' => $detection,
|
||
'decision_token' => $token,
|
||
'options' => [
|
||
'restore' => [
|
||
'mode' => 'restore',
|
||
'label' => 'Восстановить существующую конфигурацию'
|
||
],
|
||
'reinstall' => [
|
||
'mode' => 'reinstall',
|
||
'label' => 'Переустановить заново'
|
||
]
|
||
]
|
||
];
|
||
}
|
||
|
||
$installMode = 'install';
|
||
Logger::appendInstall($serverId, 'Proceeding with clean install');
|
||
}
|
||
|
||
if ($installMode === 'restore') {
|
||
Logger::appendInstall($serverId, 'Restoring existing configuration...');
|
||
if ($detectionPayload === null) {
|
||
$detectionPayload = self::detect($server, $protocol, array_merge($options, ['force' => true]));
|
||
Logger::appendInstall($serverId, 'Forced detection for restore: ' . json_encode($detectionPayload));
|
||
}
|
||
|
||
if (!in_array($detectionPayload['status'] ?? '', ['existing', 'partial'], true)) {
|
||
throw new Exception('Существующая конфигурация на сервере не найдена');
|
||
}
|
||
|
||
$res = self::restore($server, $protocol, $detectionPayload, $options);
|
||
Logger::appendInstall($serverId, 'Restore finished: ' . json_encode($res));
|
||
return $res;
|
||
}
|
||
|
||
if ($installMode === 'reinstall') {
|
||
$serverData = $server->getData();
|
||
Logger::appendInstall($serverId, 'Reinstall mode selected');
|
||
if (($serverData['status'] ?? '') === 'active' && empty($options['skip_backup'])) {
|
||
try {
|
||
$server->createBackup((int) $serverData['user_id'], 'automatic');
|
||
Logger::appendInstall($serverId, 'Automatic backup created before reinstall');
|
||
} catch (Throwable $e) {
|
||
Logger::appendInstall($serverId, 'Backup before reinstall failed: ' . $e->getMessage());
|
||
// backup errors do not abort reinstall
|
||
}
|
||
}
|
||
}
|
||
|
||
return self::install($server, $protocol, $options);
|
||
} catch (Throwable $e) {
|
||
// Mark server error and log
|
||
self::markServerError($server->getId(), $e->getMessage());
|
||
Logger::appendInstall($server->getId(), 'Deploy failed: ' . $e->getMessage());
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
private static function detect(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$handler = self::resolveHandler($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);
|
||
}
|
||
}
|
||
|
||
public static function install(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$engine = self::getEngine($protocol);
|
||
$serverId = $server->getId();
|
||
if ($engine === 'builtin_awg') {
|
||
try {
|
||
Logger::appendInstall($serverId, 'Installing builtin AWG...');
|
||
$result = $server->runAwgInstall($options);
|
||
Logger::appendInstall($serverId, 'Builtin AWG install finished: ' . json_encode($result));
|
||
self::markServerActive($serverId, null, [
|
||
'vpn_port' => $result['vpn_port'] ?? null,
|
||
'server_public_key' => $result['public_key'] ?? ($result['server_public_key'] ?? null),
|
||
'preshared_key' => $result['preshared_key'] ?? null,
|
||
'awg_params' => $result['awg_params'] ?? null,
|
||
]);
|
||
return $result;
|
||
} catch (Throwable $e) {
|
||
Logger::appendInstall($serverId, 'AWG install failed: ' . $e->getMessage());
|
||
self::markServerError($serverId, $e->getMessage());
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
try {
|
||
Logger::appendInstall($serverId, 'Running scripted install...');
|
||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||
// Choose/ensure VPN UDP port for script-driven installs
|
||
if (($protocol['slug'] ?? '') === 'xray-vless' && (!isset($options['server_port']) || !is_int($options['server_port']) || $options['server_port'] <= 0)) {
|
||
$options['server_port'] = 443;
|
||
}
|
||
if (!isset($options['server_port']) || !is_int($options['server_port'])) {
|
||
$options['server_port'] = self::chooseServerPort($server, $metadata);
|
||
}
|
||
$result = self::runScript($server, $protocol, 'install', $options);
|
||
if (!isset($result['success'])) {
|
||
$result['success'] = true;
|
||
}
|
||
Logger::appendInstall($serverId, 'Scripted install finished: ' . json_encode($result));
|
||
|
||
$rawPort = $result['vpn_port'] ?? null;
|
||
$resolvedPort = (is_numeric($rawPort) && (int) $rawPort > 0)
|
||
? (int) $rawPort
|
||
: ($options['server_port'] ?? null);
|
||
|
||
$awgParams = $result['awg_params'] ?? null;
|
||
if (!is_array($awgParams)) {
|
||
$flat = [];
|
||
foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4', 'I1', 'I2', 'I3', 'I4', 'I5'] as $k) {
|
||
if (array_key_exists($k, $result) && $result[$k] !== '' && $result[$k] !== null) {
|
||
$flat[$k] = $result[$k];
|
||
}
|
||
}
|
||
if (!empty($flat)) {
|
||
$awgParams = $flat;
|
||
}
|
||
}
|
||
|
||
$extras = [
|
||
'vpn_port' => $resolvedPort,
|
||
'server_public_key' => $result['server_public_key'] ?? null,
|
||
'preshared_key' => $result['preshared_key'] ?? null,
|
||
'awg_params' => $awgParams,
|
||
'secret' => $result['secret'] ?? null,
|
||
'server_host' => $result['server_host'] ?? null,
|
||
'container_name' => $result['container_name'] ?? ($metadata['container_name'] ?? null),
|
||
];
|
||
if (($protocol['slug'] ?? '') === 'aivpn' && array_key_exists('connection_key', $result)) {
|
||
$extras['connection_key'] = $result['connection_key'];
|
||
}
|
||
if (($protocol['slug'] ?? '') === 'xray-vless') {
|
||
foreach (['client_id', 'container_name', 'server_port', 'xray_port', 'reality_public_key', 'reality_private_key', 'reality_short_id', 'reality_server_name'] as $k) {
|
||
if (array_key_exists($k, $result)) {
|
||
$extras[$k] = $result[$k];
|
||
}
|
||
}
|
||
$extras['result'] = $result;
|
||
}
|
||
self::markServerActive($serverId, null, $extras);
|
||
return $result;
|
||
} catch (Throwable $e) {
|
||
Logger::appendInstall($serverId, 'Scripted install failed: ' . $e->getMessage());
|
||
self::markServerError($serverId, $e->getMessage());
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
private static function restore(VpnServer $server, array $protocol, array $detection, array $options = []): array
|
||
{
|
||
$handler = self::resolveHandler($protocol);
|
||
|
||
switch ($handler) {
|
||
case 'awg':
|
||
return self::restoreBuiltinAwg($server, $protocol, $detection, $options);
|
||
case 'xray':
|
||
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;
|
||
}
|
||
}
|
||
|
||
private static function detectBuiltinAwg(VpnServer $server, array $protocol): array
|
||
{
|
||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||
$serverData = $server->getData();
|
||
// For multi-protocol servers, use container_name from protocol metadata first
|
||
// (vpn_servers.container_name stores the primary protocol's container, e.g. 'aivpn-server')
|
||
$containerName = $metadata['container_name'] ?? ($serverData['container_name'] ?? 'amnezia-awg');
|
||
$containerFilter = escapeshellarg('^' . $containerName . '$');
|
||
$containerArg = escapeshellarg($containerName);
|
||
|
||
// AWG2 uses awg0.conf (standard, same as native Amnezia app)
|
||
// Old AWG uses wg0.conf
|
||
$isAwg2 = (stripos($containerName, 'awg2') !== false || ($protocol['slug'] ?? '') === 'awg2');
|
||
$configDir = '/opt/amnezia/awg';
|
||
$configFile = $isAwg2 ? 'awg0.conf' : 'wg0.conf';
|
||
|
||
$containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true));
|
||
if ($containerListRaw === '') {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Контейнер AmneziaWG не найден на сервере'
|
||
];
|
||
}
|
||
|
||
if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Docker CLI недоступен на сервере',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerListRaw,
|
||
]
|
||
];
|
||
}
|
||
|
||
$containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw))));
|
||
if (!in_array($containerName, $containerNames, true)) {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Контейнер AmneziaWG не найден на сервере'
|
||
];
|
||
}
|
||
|
||
$containerState = trim($server->executeCommand("docker inspect --format '{{.State.Status}}' {$containerArg}", true));
|
||
|
||
// AWG2: try awg0.conf first (standard), fall back to wg0.conf (legacy panel installs)
|
||
$configFile = $isAwg2 ? 'awg0.conf' : 'wg0.conf';
|
||
$wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true);
|
||
if ($isAwg2 && (trim($wgConfig) === '' || strpos($wgConfig, '[Interface]') === false)) {
|
||
// Fallback to wg0.conf for legacy panel installs
|
||
$configFile = 'wg0.conf';
|
||
$wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true);
|
||
}
|
||
if (trim($wgConfig) === '' || strpos($wgConfig, '[Interface]') === false) {
|
||
return [
|
||
'status' => 'partial',
|
||
'message' => "Контейнер найден, но конфигурация wg0.conf/awg0.conf отсутствует",
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
]
|
||
];
|
||
}
|
||
|
||
$parsedConfig = self::parseWireGuardConfig($wgConfig);
|
||
if (empty($parsedConfig['listen_port']) || empty($parsedConfig['awg_params'])) {
|
||
return [
|
||
'status' => 'partial',
|
||
'message' => 'Не удалось разобрать конфигурацию wg0.conf',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
]
|
||
];
|
||
}
|
||
|
||
$publicKey = trim($server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/wireguard_server_public_key.key 2>/dev/null", true));
|
||
$presharedKey = trim($server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/wireguard_psk.key 2>/dev/null", true));
|
||
|
||
if ($publicKey === '' || $presharedKey === '') {
|
||
return [
|
||
'status' => 'partial',
|
||
'message' => 'Не удалось прочитать ключи сервера',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
]
|
||
];
|
||
}
|
||
|
||
$clientsRaw = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/clientsTable 2>/dev/null", true);
|
||
$clients = json_decode(trim($clientsRaw), true);
|
||
$clientsCount = is_array($clients) ? count($clients) : 0;
|
||
|
||
return [
|
||
'status' => 'existing',
|
||
'message' => 'Найдена установленная конфигурация AmneziaWG',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
'vpn_port' => (int) $parsedConfig['listen_port'],
|
||
'server_public_key' => $publicKey,
|
||
'preshared_key' => $presharedKey,
|
||
'awg_params' => $parsedConfig['awg_params'],
|
||
'clients_count' => $clientsCount,
|
||
'summary' => sprintf('Container %s (%s), port %s, clients %d', $containerName, $containerState ?: 'unknown', $parsedConfig['listen_port'], $clientsCount)
|
||
]
|
||
];
|
||
}
|
||
|
||
private static function restoreBuiltinAwg(VpnServer $server, array $protocol, array $detection, array $options): array
|
||
{
|
||
$details = $detection['details'] ?? [];
|
||
$containerName = $details['container_name'] ?? ($protocol['definition']['metadata']['container_name'] ?? 'amnezia-awg');
|
||
$containerArg = escapeshellarg($containerName);
|
||
|
||
// Config is always wg0.conf — container CMD runs: awg-quick up /opt/amnezia/awg/wg0.conf
|
||
$isAwg2 = (stripos($containerName, 'awg2') !== false || ($protocol['slug'] ?? '') === 'awg2');
|
||
$configDir = '/opt/amnezia/awg';
|
||
// AWG2: try awg0.conf first (standard), fall back to wg0.conf (legacy)
|
||
$configFile = $isAwg2 ? 'awg0.conf' : 'wg0.conf';
|
||
$testConf = trim($server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true));
|
||
if ($isAwg2 && ($testConf === '' || strpos($testConf, '[Interface]') === false)) {
|
||
$configFile = 'wg0.conf';
|
||
}
|
||
|
||
// Determine interface name from config filename (wg0.conf -> wg0, awg0.conf -> awg0)
|
||
$ifaceName = str_replace('.conf', '', $configFile);
|
||
|
||
// Try to ensure container is running and wg is up
|
||
$server->executeCommand("docker start {$containerArg} 2>/dev/null || true", true);
|
||
$server->executeCommand("docker exec -i {$containerArg} wg-quick down {$configDir}/{$configFile} 2>/dev/null || true", true);
|
||
$server->executeCommand("docker exec -i {$containerArg} wg-quick up {$configDir}/{$configFile} 2>/dev/null || true", true);
|
||
|
||
$pdo = DB::conn();
|
||
$serverData = $server->getData();
|
||
$serverId = $server->getId();
|
||
$protocolId = self::resolveProtocolId($protocol);
|
||
$protocolSlug = $protocol['slug'] ?? ($isAwg2 ? 'awg2' : 'amnezia-wg');
|
||
|
||
// Check if server already has another primary protocol installed
|
||
$existingProtocol = $serverData['install_protocol'] ?? '';
|
||
$isSecondaryProtocol = ($existingProtocol !== '' && $existingProtocol !== $protocolSlug);
|
||
|
||
if (!$isSecondaryProtocol) {
|
||
// Primary protocol — update vpn_servers
|
||
$stmt = $pdo->prepare('
|
||
UPDATE vpn_servers
|
||
SET vpn_port = ?,
|
||
server_public_key = ?,
|
||
preshared_key = ?,
|
||
awg_params = ?,
|
||
status = ?,
|
||
error_message = NULL,
|
||
deployed_at = COALESCE(deployed_at, NOW()),
|
||
install_protocol = ?
|
||
WHERE id = ?
|
||
');
|
||
$stmt->execute([
|
||
$details['vpn_port'] ?? null,
|
||
$details['server_public_key'] ?? null,
|
||
$details['preshared_key'] ?? null,
|
||
isset($details['awg_params']) ? json_encode($details['awg_params']) : null,
|
||
'active',
|
||
$protocolSlug,
|
||
$serverId
|
||
]);
|
||
} else {
|
||
// Secondary protocol — only ensure server is active, don't overwrite primary protocol data
|
||
$stmt = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = NULL WHERE id = ?');
|
||
$stmt->execute(['active', $serverId]);
|
||
}
|
||
|
||
// Store protocol-specific config in server_protocols (works for both primary and secondary)
|
||
if ($protocolId) {
|
||
$configData = json_encode([
|
||
'server_host' => $serverData['ip_address'] ?? $serverData['hostname'] ?? null,
|
||
'server_port' => $details['vpn_port'] ?? null,
|
||
'extras' => [
|
||
'vpn_port' => $details['vpn_port'] ?? null,
|
||
'vpn_subnet' => $details['vpn_subnet'] ?? '10.8.1.0/24',
|
||
'server_public_key' => $details['server_public_key'] ?? null,
|
||
'preshared_key' => $details['preshared_key'] ?? null,
|
||
'awg_params' => $details['awg_params'] ?? null,
|
||
'container_name' => $containerName,
|
||
],
|
||
]);
|
||
$stmt = $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()
|
||
');
|
||
$stmt->execute([$serverId, $protocolId, $configData]);
|
||
}
|
||
|
||
$server->refresh();
|
||
$serverData = $server->getData();
|
||
|
||
// Import existing peers from config into database as disabled clients
|
||
$serverId = $server->getId();
|
||
Logger::appendInstall($serverId, "Restore: configDir={$configDir}, configFile={$configFile}, containerArg={$containerArg}");
|
||
$wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true);
|
||
$tableRaw = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/clientsTable 2>/dev/null", true);
|
||
Logger::appendInstall($serverId, "Restore: wgConfig length=" . strlen($wgConfig) . ", tableRaw length=" . strlen($tableRaw));
|
||
$clientsTable = json_decode(trim($tableRaw), true);
|
||
$nameByPub = [];
|
||
if (is_array($clientsTable)) {
|
||
foreach ($clientsTable as $entry) {
|
||
$cid = $entry['clientId'] ?? '';
|
||
$uname = $entry['userData']['clientName'] ?? null;
|
||
if ($cid !== '' && $uname) {
|
||
$nameByPub[$cid] = $uname;
|
||
}
|
||
}
|
||
}
|
||
$restored = 0;
|
||
$pid = self::resolveProtocolId($protocol);
|
||
$needsServerConfigUpdate = false;
|
||
$keyUpdates = []; // Array of ['old' => $oldPub, 'new' => $newPub]
|
||
Logger::appendInstall($serverId, "Restore: protocol_id={$pid}, wgConfig empty=" . (trim($wgConfig) === '' ? 'yes' : 'no'));
|
||
if (trim($wgConfig) !== '') {
|
||
$pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/';
|
||
if (preg_match_all($pattern, $wgConfig, $matches, PREG_SET_ORDER)) {
|
||
foreach ($matches as $m) {
|
||
$pub = trim($m[1]);
|
||
$allowed = trim($m[2]);
|
||
$clientIp = null;
|
||
foreach (explode(',', $allowed) as $ipSpec) {
|
||
$ipSpec = trim($ipSpec);
|
||
if (preg_match('/^([0-9\.]+)\/32$/', $ipSpec, $mm)) {
|
||
$clientIp = $mm[1];
|
||
break;
|
||
}
|
||
}
|
||
if (!$clientIp) {
|
||
continue;
|
||
}
|
||
$pdo = DB::conn();
|
||
$chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND client_ip = ?');
|
||
$chk->execute([$server->getId(), $clientIp]);
|
||
if ($chk->fetch()) {
|
||
continue;
|
||
}
|
||
$name = $nameByPub[$pub] ?? ('import-' . str_replace('.', '_', $clientIp));
|
||
|
||
// Try to find existing client in database with this public key
|
||
$stmt = $pdo->prepare('SELECT id, private_key, config, qr_code FROM vpn_clients WHERE server_id = ? AND public_key = ? LIMIT 1');
|
||
$stmt->execute([$server->getId(), $pub]);
|
||
$existingClient = $stmt->fetch();
|
||
|
||
$privateKey = $existingClient['private_key'] ?? '';
|
||
$config = $existingClient['config'] ?? '';
|
||
$qrCode = $existingClient['qr_code'] ?? '';
|
||
$newPublicKey = $pub; // By default use existing public key
|
||
|
||
// If client exists in DB with private key, use existing config
|
||
// Otherwise generate new key pair and update server config
|
||
if (!empty($privateKey) && $existingClient) {
|
||
// Use existing keys and config
|
||
} else {
|
||
// Generate new key pair for this client
|
||
// Use awg for AWG2, wg for standard
|
||
$keyTool = $isAwg2 ? 'awg' : 'wg';
|
||
$newPrivateKey = trim($server->executeCommand("docker exec {$containerArg} {$keyTool} genkey", true));
|
||
if (!empty($newPrivateKey)) {
|
||
$escapedKey = escapeshellarg($newPrivateKey);
|
||
$newPublicKey = trim($server->executeCommand("docker exec {$containerArg} sh -c 'echo {$escapedKey} | {$keyTool} pubkey'", true));
|
||
} else {
|
||
$newPublicKey = '';
|
||
}
|
||
|
||
Logger::appendInstall($serverId, "Restore: keygen for {$clientIp}: privkey_len=" . strlen($newPrivateKey) . " pubkey_len=" . strlen($newPublicKey));
|
||
|
||
if (!empty($newPrivateKey) && !empty($newPublicKey) && strlen($newPublicKey) >= 40) {
|
||
$privateKey = $newPrivateKey;
|
||
$protocolSlug = $protocol['slug'] ?? '';
|
||
$serverHost = $serverData['host'] ?? $serverData['ip_address'] ?? $serverData['hostname'] ?? '';
|
||
$config = VpnClient::buildClientConfig(
|
||
$privateKey,
|
||
$clientIp,
|
||
$details['server_public_key'] ?? '',
|
||
$details['preshared_key'] ?? '',
|
||
$serverHost,
|
||
$details['vpn_port'] ?? 51820,
|
||
$details['awg_params'] ?? [],
|
||
$protocolSlug
|
||
);
|
||
$qrCode = VpnClient::generateQRCode($config, $protocolSlug);
|
||
|
||
// Mark that we need to update server config with new public key
|
||
$needsServerConfigUpdate = true;
|
||
$keyUpdates[] = ['old' => $pub, 'new' => $newPublicKey];
|
||
} else {
|
||
Logger::appendInstall($serverId, "Restore: WARNING keygen failed for {$clientIp}, keeping original public key");
|
||
}
|
||
}
|
||
|
||
$ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, qr_code, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())');
|
||
$ins->execute([
|
||
$server->getId(),
|
||
$serverData['user_id'] ?? null,
|
||
$name,
|
||
$clientIp,
|
||
$newPublicKey,
|
||
$privateKey,
|
||
$details['preshared_key'] ?? null,
|
||
$config,
|
||
$qrCode,
|
||
$pid ?: null,
|
||
'active'
|
||
]);
|
||
$restored++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update server config if any keys were regenerated
|
||
if ($needsServerConfigUpdate && !empty($keyUpdates)) {
|
||
Logger::appendInstall($serverId, "Restore: updating server config with " . count($keyUpdates) . " new public keys");
|
||
|
||
// Update wg0.conf - replace old public keys with new ones
|
||
$updatedConfig = $wgConfig;
|
||
foreach ($keyUpdates as $update) {
|
||
// Escape special characters for regex
|
||
$oldEscaped = preg_quote($update['old'], '/');
|
||
$updatedConfig = preg_replace(
|
||
'/(PublicKey\s*=\s*)' . $oldEscaped . '/',
|
||
'${1}' . $update['new'],
|
||
$updatedConfig
|
||
);
|
||
}
|
||
|
||
// Write updated config back to container
|
||
$escapedConfig = addslashes($updatedConfig);
|
||
$server->executeCommand("docker exec -i {$containerArg} sh -c 'echo \"$escapedConfig\" > {$configDir}/{$configFile}'", true);
|
||
|
||
// Update clientsTable with new public keys
|
||
$updatedTable = $clientsTable;
|
||
if (is_array($updatedTable)) {
|
||
foreach ($keyUpdates as $update) {
|
||
foreach ($updatedTable as &$entry) {
|
||
if (($entry['clientId'] ?? '') === $update['old']) {
|
||
$entry['clientId'] = $update['new'];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$tableJson = addslashes(json_encode($updatedTable, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||
$server->executeCommand("docker exec -i {$containerArg} sh -c 'echo \"$tableJson\" > {$configDir}/clientsTable'", true);
|
||
|
||
// Restart WireGuard interface to apply changes
|
||
$server->executeCommand("docker exec -i {$containerArg} wg-quick down {$configDir}/{$configFile} 2>/dev/null || true", true);
|
||
$server->executeCommand("docker exec -i {$containerArg} wg-quick up {$configDir}/{$configFile} 2>/dev/null || true", true);
|
||
|
||
Logger::appendInstall($serverId, "Restore: server config updated, WireGuard restarted");
|
||
}
|
||
|
||
Logger::appendInstall($serverId, "Restore: finished, restored={$restored}");
|
||
return [
|
||
'success' => true,
|
||
'mode' => 'restore',
|
||
'message' => 'Существующая конфигурация восстановлена' . ($needsServerConfigUpdate ? ' (ключи клиентов обновлены)' : ''),
|
||
'vpn_port' => $details['vpn_port'] ?? null,
|
||
'clients_count' => $details['clients_count'] ?? null,
|
||
'restored_clients' => $restored
|
||
];
|
||
}
|
||
|
||
public static function addClient(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
return self::runScript($server, $protocol, 'add_client', $options);
|
||
}
|
||
|
||
private static function runScript(VpnServer $server, array $protocol, string $phase, array $options = []): array
|
||
{
|
||
$definition = $protocol['definition'] ?? [];
|
||
$scripts = $definition['scripts'][$phase] ?? null;
|
||
if (!$scripts) {
|
||
if ($phase === 'install') {
|
||
$scripts = $protocol['install_script'] ?? null;
|
||
} elseif ($phase === 'uninstall') {
|
||
$scripts = $protocol['uninstall_script'] ?? null;
|
||
} elseif ($phase === 'add_client' && ($protocol['slug'] ?? '') === 'xray-vless') {
|
||
return self::runBuiltinXrayAddClient($server, $options);
|
||
}
|
||
}
|
||
if (!$scripts) {
|
||
if ($phase === 'detect') {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Скрипт обнаружения не настроен для протокола'
|
||
];
|
||
}
|
||
if ($phase === 'uninstall') {
|
||
return [
|
||
'success' => true,
|
||
'message' => 'Скрипт удаления не настроен для протокола'
|
||
];
|
||
}
|
||
if ($phase === 'add_client') {
|
||
if (($protocol['slug'] ?? '') === 'aivpn') {
|
||
return self::runBuiltinAivpnAddClient($server, $options);
|
||
}
|
||
// If no script and no builtin handler, we just skip it (assume not needed or manual)
|
||
// Or throw generic error? Better return success to not break flow if not implemented for other protocols
|
||
return ['success' => true, 'message' => 'No add_client script defined'];
|
||
}
|
||
throw new Exception('Скрипт ' . $phase . ' не настроен для протокола');
|
||
}
|
||
|
||
$context = self::buildContext($server, $protocol, $options);
|
||
$script = self::renderTemplate($scripts, $context);
|
||
$script = preg_replace('/\n\+\s*/', "\n", $script);
|
||
$exportLines = self::buildExports($context);
|
||
|
||
if ($phase === 'install') {
|
||
Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight start');
|
||
$bootstrapCmd = "bash -lc 'set -e; "
|
||
. "if command -v docker >/dev/null 2>&1; then command -v docker; docker --version || true; exit 0; fi; "
|
||
. "if command -v curl >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh; "
|
||
. "elif command -v wget >/dev/null 2>&1; then wget -qO- https://get.docker.com | sh; "
|
||
. "else echo \"curl/wget not found\"; exit 127; fi; "
|
||
. "(systemctl enable --now docker || service docker start || true); "
|
||
. "command -v docker >/dev/null 2>&1 || { echo \"docker bootstrap failed\"; exit 127; }; "
|
||
. "command -v docker; docker --version || true'";
|
||
$bootstrapOut = trim((string) $server->executeCommand($bootstrapCmd, true));
|
||
if ($bootstrapOut !== '') {
|
||
$bootstrapHead = substr(str_replace(["\r", "\n"], ' ', $bootstrapOut), 0, 280);
|
||
Logger::appendInstall($server->getId(), 'INSTALL phase: docker preflight output ' . $bootstrapHead);
|
||
}
|
||
|
||
$dockerCheckAfter = trim((string) $server->executeCommand('command -v docker || true', true));
|
||
if ($dockerCheckAfter === '') {
|
||
throw new Exception('Docker не установлен на сервере и авто-установка не удалась');
|
||
}
|
||
}
|
||
|
||
$wrapper = "bash <<'EOS'\nset -eo pipefail\n" . $exportLines . $script . "\nEOS";
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: executing remote script');
|
||
$output = $server->executeCommand($wrapper, true);
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output size ' . strlen((string) $output) . ' bytes');
|
||
$head = substr(str_replace(["\r", "\n"], ' ', (string) $output), 0, 280);
|
||
if ($head !== '') {
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: output head ' . $head);
|
||
}
|
||
$trimmed = trim($output);
|
||
$installProbeSummary = '';
|
||
|
||
if ($phase === 'install' && $trimmed === '') {
|
||
$probeCmd = "echo whoami:\$(whoami) 2>/dev/null || true; echo shell:\$SHELL; command -v docker || echo docker:not-found; docker --version 2>&1 || true; id 2>&1 || true";
|
||
$probeOut = trim((string) $server->executeCommand($probeCmd, true));
|
||
if ($probeOut !== '') {
|
||
$normalizedProbe = substr(str_replace(["\r", "\n"], ' | ', $probeOut), 0, 320);
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: probe ' . $normalizedProbe);
|
||
$installProbeSummary = '; probe: ' . $normalizedProbe;
|
||
}
|
||
}
|
||
|
||
// Try JSON first
|
||
$decoded = json_decode($trimmed, true);
|
||
if (is_array($decoded)) {
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: parsed JSON result');
|
||
return $decoded;
|
||
}
|
||
|
||
if ($phase === 'install') {
|
||
$lower = strtolower($trimmed);
|
||
$hardErrors = [
|
||
'connection refused',
|
||
'permission denied',
|
||
'command not found',
|
||
'no route to host',
|
||
'could not resolve hostname',
|
||
'host key verification failed',
|
||
'timed out',
|
||
'operation timed out',
|
||
];
|
||
foreach ($hardErrors as $needle) {
|
||
if ($needle !== '' && strpos($lower, $needle) !== false) {
|
||
throw new Exception('Ошибка установки (script): ' . $trimmed);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try key-value format (e.g., "Port: 123" or "Server Public Key: abc")
|
||
$result = self::parseKeyValueOutput($trimmed);
|
||
if (!empty($result)) {
|
||
Logger::appendInstall($server->getId(), strtoupper($phase) . ' phase: parsed key-value result with ' . count($result) . ' keys');
|
||
return array_merge(['success' => true], $result);
|
||
}
|
||
|
||
// Heuristic: treat obvious errors on install as failure to avoid false "active" status
|
||
if ($phase === 'install') {
|
||
$lower = strtolower($trimmed);
|
||
if ($lower === '' || strpos($lower, 'command not found') !== false || strpos($lower, 'error') !== false) {
|
||
throw new Exception('Ошибка установки (script): ' . ($trimmed !== '' ? $trimmed : 'empty output') . $installProbeSummary);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'success' => true,
|
||
'output' => $output
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Parse key-value output from installation scripts
|
||
* Supports formats like:
|
||
* - "Port: 123"
|
||
* - "Server Public Key: abc123"
|
||
* - "PresharedKey = xyz789"
|
||
*/
|
||
private static function parseKeyValueOutput(string $output): array
|
||
{
|
||
$result = [];
|
||
$lines = preg_split('/\r?\n/', $output);
|
||
|
||
foreach ($lines as $line) {
|
||
$line = trim($line);
|
||
if ($line === '')
|
||
continue;
|
||
$line = preg_replace('/^\+\s*/', '', $line);
|
||
|
||
// Match "Variable: name=value" format (for protocol variables)
|
||
if (preg_match('/^Variable:\s*(\w+)=(.*)$/', $line, $matches)) {
|
||
$varName = trim($matches[1]);
|
||
$varValue = trim($matches[2]);
|
||
$result[$varName] = $varValue;
|
||
continue;
|
||
}
|
||
|
||
// Match "Key: Value" or "Key = Value" format
|
||
if (preg_match('/^([^:=]+?)[:=]\s*(.+)$/', $line, $matches)) {
|
||
$key = trim($matches[1]);
|
||
$value = trim($matches[2]);
|
||
|
||
// Normalize key names to snake_case
|
||
$normalizedKey = strtolower(preg_replace('/\s+/', '_', $key));
|
||
|
||
// Map common key names
|
||
$keyMap = [
|
||
'port' => 'vpn_port',
|
||
'server_public_key' => 'server_public_key',
|
||
'presharedkey' => 'preshared_key',
|
||
'preshared_key' => 'preshared_key',
|
||
'awg_params' => 'awg_params',
|
||
'clientid' => 'client_id',
|
||
'client_id' => 'client_id',
|
||
'server_port' => 'server_port',
|
||
'xray_port' => 'server_port',
|
||
'container_name' => 'container_name',
|
||
'containername' => 'container_name',
|
||
'publickey' => 'reality_public_key',
|
||
'privatekey' => 'reality_private_key',
|
||
'shortid' => 'reality_short_id',
|
||
'servername' => 'reality_server_name',
|
||
'secret' => 'secret',
|
||
'serverhost' => 'server_host',
|
||
'server_host' => 'server_host',
|
||
];
|
||
|
||
$finalKey = $keyMap[$normalizedKey] ?? $normalizedKey;
|
||
$result[$finalKey] = $value;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private static function markServerActive(int $serverId, ?string $message = null, array $extras = []): void
|
||
{
|
||
$pdo = DB::conn();
|
||
$setParts = ['status = ?', 'error_message = NULL', 'deployed_at = COALESCE(deployed_at, NOW())'];
|
||
$params = ['active'];
|
||
if (isset($extras['vpn_port']) && $extras['vpn_port'] !== null) {
|
||
$setParts[] = 'vpn_port = ?';
|
||
$params[] = (int) $extras['vpn_port'];
|
||
}
|
||
if (isset($extras['server_public_key']) && $extras['server_public_key'] !== null) {
|
||
$setParts[] = 'server_public_key = ?';
|
||
$params[] = (string) $extras['server_public_key'];
|
||
}
|
||
if (isset($extras['preshared_key']) && $extras['preshared_key'] !== null) {
|
||
$setParts[] = 'preshared_key = ?';
|
||
$params[] = (string) $extras['preshared_key'];
|
||
}
|
||
if (isset($extras['container_name']) && $extras['container_name'] !== null && $extras['container_name'] !== '') {
|
||
$setParts[] = 'container_name = ?';
|
||
$params[] = (string) $extras['container_name'];
|
||
}
|
||
if (array_key_exists('awg_params', $extras)) {
|
||
$awgParams = $extras['awg_params'];
|
||
if (is_array($awgParams)) {
|
||
$awgParams = json_encode($awgParams);
|
||
}
|
||
if (is_string($awgParams)) {
|
||
$setParts[] = 'awg_params = ?';
|
||
$params[] = $awgParams;
|
||
}
|
||
}
|
||
$params[] = $serverId;
|
||
$sql = 'UPDATE vpn_servers SET ' . implode(', ', $setParts) . ' WHERE id = ?';
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
|
||
try {
|
||
$stmt2 = $pdo->prepare('SELECT install_protocol, host, vpn_port FROM vpn_servers WHERE id = ?');
|
||
$stmt2->execute([$serverId]);
|
||
$row = $stmt2->fetch();
|
||
$slug = $row['install_protocol'] ?? null;
|
||
if ($slug) {
|
||
$stmt3 = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1');
|
||
$stmt3->execute([$slug]);
|
||
$protocolId = $stmt3->fetchColumn();
|
||
if ($protocolId) {
|
||
$config = [
|
||
'server_host' => $row['host'] ?? null,
|
||
'server_port' => $row['vpn_port'] ?? null,
|
||
'extras' => $extras
|
||
];
|
||
$stmt4 = $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()');
|
||
$stmt4->execute([$serverId, (int) $protocolId, json_encode($config)]);
|
||
|
||
// Keep existing MTProxy client links in sync with current runtime port/secret after reinstall.
|
||
if ($slug === 'mtproxy') {
|
||
$mtHost = (string) ($config['server_host'] ?? '');
|
||
$mtPort = (string) ($config['server_port'] ?? '');
|
||
$mtSecret = '';
|
||
|
||
if (!empty($extras['secret']) && is_scalar($extras['secret'])) {
|
||
$mtSecret = trim((string) $extras['secret']);
|
||
}
|
||
if ($mtSecret === '' && isset($extras['result']) && is_array($extras['result'])) {
|
||
if (!empty($extras['result']['secret']) && is_scalar($extras['result']['secret'])) {
|
||
$mtSecret = trim((string) $extras['result']['secret']);
|
||
}
|
||
}
|
||
|
||
if ($mtHost !== '' && $mtPort !== '' && $mtSecret !== '') {
|
||
$mtLink = 'tg://proxy?server=' . $mtHost . '&port=' . $mtPort . '&secret=' . $mtSecret;
|
||
$stmtSync = $pdo->prepare('UPDATE vpn_clients SET config = ? WHERE server_id = ? AND protocol_id = ? AND (config IS NULL OR config = "" OR config LIKE "tg://proxy?%")');
|
||
$stmtSync->execute([$mtLink, $serverId, (int) $protocolId]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
// ignore linkage errors
|
||
}
|
||
}
|
||
|
||
private static function markServerError(int $serverId, string $message): void
|
||
{
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('UPDATE vpn_servers SET status = ?, error_message = ? WHERE id = ?');
|
||
$stmt->execute(['error', $message, $serverId]);
|
||
}
|
||
|
||
private static function buildContext(VpnServer $server, array $protocol, array $options): array
|
||
{
|
||
return [
|
||
'server' => $server->getData(),
|
||
'protocol' => $protocol,
|
||
'metadata' => $protocol['definition']['metadata'] ?? [],
|
||
'options' => $options
|
||
];
|
||
}
|
||
|
||
private static function buildExports(array $context): string
|
||
{
|
||
$exports = [];
|
||
$serverData = $context['server'] ?? [];
|
||
$metadata = $context['metadata'] ?? [];
|
||
$options = $context['options'] ?? [];
|
||
|
||
$pairs = [
|
||
'SERVER_HOST' => $serverData['host'] ?? '',
|
||
'SERVER_USER' => $serverData['username'] ?? '',
|
||
// Prefer protocol-specific settings for scripted installs to avoid
|
||
// reusing a container name/port from another protocol on same server.
|
||
'SERVER_CONTAINER' => $options['container_name']
|
||
?? ($metadata['container_name'] ?? ($serverData['container_name'] ?? '')),
|
||
'SERVER_PORT' => isset($options['server_port']) && (int) $options['server_port'] > 0
|
||
? (int) $options['server_port']
|
||
: (isset($serverData['vpn_port']) && (int) $serverData['vpn_port'] > 0
|
||
? (int) $serverData['vpn_port']
|
||
: ''),
|
||
];
|
||
|
||
// Check for saved Reality keys in server_protocols table
|
||
$serverId = $serverData['id'] ?? null;
|
||
if ($serverId) {
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('SELECT config_data FROM server_protocols WHERE server_id = ? ORDER BY applied_at DESC LIMIT 1');
|
||
$stmt->execute([$serverId]);
|
||
$configJson = $stmt->fetchColumn();
|
||
if ($configJson) {
|
||
$config = json_decode($configJson, true);
|
||
$extras = $config['extras'] ?? [];
|
||
// Export saved Reality keys if reinstalling (allow script to reuse them)
|
||
if (!empty($extras['reality_private_key'])) {
|
||
$pairs['PRIVATE_KEY'] = $extras['reality_private_key'];
|
||
}
|
||
if (!empty($extras['reality_short_id'])) {
|
||
$pairs['SHORT_ID'] = $extras['reality_short_id'];
|
||
}
|
||
// Note: CLIENT_ID is per-client, not per-server, so we don't restore it here
|
||
}
|
||
} catch (Throwable $e) {
|
||
// Ignore errors, will generate new keys
|
||
}
|
||
}
|
||
|
||
foreach ($pairs as $key => $value) {
|
||
if ($value !== '' && $value !== null) {
|
||
$exports[] = sprintf('export %s=%s', $key, escapeshellarg((string) $value));
|
||
}
|
||
}
|
||
|
||
foreach ($metadata as $key => $value) {
|
||
if (!is_scalar($value)) {
|
||
continue;
|
||
}
|
||
$normalized = strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', (string) $key));
|
||
if ($normalized === '') {
|
||
continue;
|
||
}
|
||
$exports[] = sprintf('export PROTOCOL_%s=%s', $normalized, escapeshellarg((string) $value));
|
||
}
|
||
|
||
return $exports ? implode("\n", $exports) . "\n" : '';
|
||
}
|
||
|
||
/**
|
||
* Choose a free UDP port on the remote server within metadata-defined range or defaults
|
||
*/
|
||
private static function chooseServerPort(VpnServer $server, array $metadata): int
|
||
{
|
||
$range = $metadata['port_range'] ?? [30000, 65000];
|
||
$min = 30000;
|
||
$max = 65000;
|
||
if (is_string($range)) {
|
||
// Accept formats like "[30000, 65000]" or "30000-65000"
|
||
if (preg_match('/(\d{2,})\D+(\d{2,})/', $range, $m)) {
|
||
$min = (int) $m[1];
|
||
$max = (int) $m[2];
|
||
}
|
||
} elseif (is_array($range) && count($range) >= 2) {
|
||
$min = (int) $range[0];
|
||
$max = (int) $range[1];
|
||
}
|
||
|
||
for ($attempt = 0; $attempt < 30; $attempt++) {
|
||
$candidate = random_int($min, $max);
|
||
$cmd = "ss -lun | awk '{print $4}' | grep -E ':(" . $candidate . ")($| )' || true";
|
||
$out = $server->executeCommand($cmd, false);
|
||
if (trim($out) === '') {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
return 40001; // fallback
|
||
}
|
||
|
||
private static function renderTemplate(string $template, array $context): string
|
||
{
|
||
return preg_replace_callback('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', function ($matches) use ($context) {
|
||
$path = explode('.', $matches[1]);
|
||
$value = $context;
|
||
foreach ($path as $segment) {
|
||
if (is_array($value) && array_key_exists($segment, $value)) {
|
||
$value = $value[$segment];
|
||
} else {
|
||
return '';
|
||
}
|
||
}
|
||
return is_scalar($value) ? (string) $value : json_encode($value);
|
||
}, $template);
|
||
}
|
||
|
||
private static function parseWireGuardConfig(string $config): array
|
||
{
|
||
$lines = preg_split('/\r?\n/', $config);
|
||
$awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4', 'I1', 'I2', 'I3', 'I4', 'I5'];
|
||
$awgParams = [];
|
||
$listenPort = null;
|
||
|
||
foreach ($lines as $line) {
|
||
$line = trim($line);
|
||
if ($line === '' || strpos($line, '=') === false) {
|
||
continue;
|
||
}
|
||
[$key, $value] = array_map('trim', explode('=', $line, 2));
|
||
if ($key === 'ListenPort') {
|
||
$listenPort = (int) $value;
|
||
}
|
||
if (in_array($key, $awgKeys, true)) {
|
||
$awgParams[$key] = is_numeric($value) ? (int) $value : $value;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'listen_port' => $listenPort,
|
||
'awg_params' => $awgParams
|
||
];
|
||
}
|
||
|
||
private static function hydrateProtocol(array $row): array
|
||
{
|
||
if (isset($row['definition']) && is_string($row['definition'])) {
|
||
$decoded = json_decode($row['definition'], true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||
$row['definition'] = $decoded;
|
||
} else {
|
||
$row['definition'] = [];
|
||
}
|
||
}
|
||
return $row;
|
||
}
|
||
|
||
/**
|
||
* ──────────────────────────────────────────────────────────────────
|
||
* 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
|
||
{
|
||
$slug = $protocol['slug'] ?? '';
|
||
|
||
// ── 1. Explicit slug → handler mapping (always wins) ──
|
||
static $slugMap = [
|
||
// WARP
|
||
'cf-warp' => 'warp',
|
||
'cloudflare-warp' => 'warp',
|
||
// X-Ray
|
||
'xray-vless' => 'xray',
|
||
// AWG variants
|
||
'amnezia-wg' => 'awg',
|
||
'amnezia-wg-advanced' => 'awg',
|
||
'awg2' => 'awg',
|
||
];
|
||
|
||
if (isset($slugMap[$slug])) {
|
||
return $slugMap[$slug];
|
||
}
|
||
|
||
// ── 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';
|
||
}
|
||
|
||
/**
|
||
* Legacy compatibility: get engine string
|
||
*/
|
||
private static function getEngine(array $protocol): string
|
||
{
|
||
$handler = self::resolveHandler($protocol);
|
||
if ($handler === 'awg') return 'builtin_awg';
|
||
return 'shell';
|
||
}
|
||
|
||
private static function fallbackProtocols(): array
|
||
{
|
||
return [
|
||
[
|
||
'id' => null,
|
||
'slug' => self::DEFAULT_SLUG,
|
||
'name' => 'AmneziaWG',
|
||
'description' => 'Default Amnezia WireGuard deployment scenario',
|
||
'definition' => [
|
||
'engine' => 'builtin_awg',
|
||
'metadata' => [
|
||
'container_name' => 'amnezia-awg',
|
||
'vpn_subnet' => '10.8.1.0/24',
|
||
'port_range' => [30000, 65000],
|
||
],
|
||
],
|
||
'is_active' => 1,
|
||
]
|
||
];
|
||
}
|
||
|
||
private static function storeDecision(int $serverId, array $payload): string
|
||
{
|
||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||
return '';
|
||
}
|
||
$token = bin2hex(random_bytes(16));
|
||
if (!isset($_SESSION[self::SESSION_KEY])) {
|
||
$_SESSION[self::SESSION_KEY] = [];
|
||
}
|
||
$_SESSION[self::SESSION_KEY][$serverId] = [
|
||
'token' => $token,
|
||
'payload' => $payload,
|
||
'expires_at' => time() + 600
|
||
];
|
||
return $token;
|
||
}
|
||
|
||
private static function consumeDecision(int $serverId, string $token): ?array
|
||
{
|
||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||
return null;
|
||
}
|
||
|
||
if (!isset($_SESSION[self::SESSION_KEY][$serverId])) {
|
||
return null;
|
||
}
|
||
|
||
$entry = $_SESSION[self::SESSION_KEY][$serverId];
|
||
if (($entry['token'] ?? '') !== $token) {
|
||
return null;
|
||
}
|
||
|
||
unset($_SESSION[self::SESSION_KEY][$serverId]);
|
||
|
||
if (($entry['expires_at'] ?? 0) < time()) {
|
||
return null;
|
||
}
|
||
|
||
return $entry['payload'] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Run detection script for a scenario on a server
|
||
* Used for testing scenarios before deployment
|
||
*/
|
||
public static function runDetection(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$handler = self::resolveHandler($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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Uninstall a protocol from the given server. Supports builtin AWG and scripted protocols
|
||
* Returns array with success and message keys on completion or throws on fatal error
|
||
*/
|
||
public static function uninstall(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$slug = $protocol['slug'] ?? 'unknown';
|
||
$handler = self::resolveHandler($protocol);
|
||
Logger::appendInstall($server->getId(), 'UNINSTALL: slug=' . $slug . ' handler=' . $handler);
|
||
|
||
switch ($handler) {
|
||
case 'warp':
|
||
return self::uninstallBuiltinWarp($server, $protocol, $options);
|
||
|
||
case 'awg':
|
||
// Prefer builtin AWG uninstall; script variant only on explicit request
|
||
if (!empty($options['use_script_uninstall'])) {
|
||
$hasScript = isset($protocol['uninstall_script']) && trim((string) $protocol['uninstall_script']) !== '';
|
||
if ($hasScript) {
|
||
return self::runScript($server, $protocol, 'uninstall', $options);
|
||
}
|
||
}
|
||
return self::uninstallBuiltinAwg($server, $protocol, $options);
|
||
|
||
case 'xray':
|
||
case 'script':
|
||
default:
|
||
return self::runScript($server, $protocol, 'uninstall', $options);
|
||
}
|
||
}
|
||
|
||
private static function uninstallBuiltinAwg(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||
$serverData = $server->getData();
|
||
// IMPORTANT: Use protocol metadata container_name first (e.g. 'amnezia-awg2'),
|
||
// NOT vpn_servers.container_name which belongs to the PRIMARY protocol (e.g. 'aivpn-server')
|
||
$containerName = $metadata['container_name'] ?? $serverData['container_name'] ?? 'amnezia-awg';
|
||
$configDir = trim((string) ($metadata['config_dir'] ?? ''));
|
||
if ($configDir === '') {
|
||
$configDir = (($protocol['slug'] ?? '') === 'awg2') ? '/opt/amnezia/awg2' : '/opt/amnezia/awg';
|
||
}
|
||
$candidateNames = array_values(array_unique(array_filter([
|
||
is_string($containerName) ? trim($containerName) : '',
|
||
is_string($metadata['container_name'] ?? null) ? trim((string) $metadata['container_name']) : '',
|
||
'amnezia-awg',
|
||
], function ($v) {
|
||
return is_string($v) && trim($v) !== '';
|
||
})));
|
||
|
||
// Attempt to stop and remove container, image and cleanup files
|
||
try {
|
||
foreach ($candidateNames as $name) {
|
||
$arg = escapeshellarg($name);
|
||
// Stop container if running
|
||
$server->executeCommand("docker stop {$arg} 2>/dev/null || true", true);
|
||
// Remove container
|
||
$server->executeCommand("docker rm -fv {$arg} 2>/dev/null || true", true);
|
||
}
|
||
// Remove known images (best-effort)
|
||
$server->executeCommand("docker rmi amneziavpn/amnezia-wg amneziavpn/amnezia-awg amnezia-awg2 2>/dev/null || true", true);
|
||
// Attempt to remove amnezia-dns-net network if present (best-effort)
|
||
$server->executeCommand("docker network rm amnezia-dns-net 2>/dev/null || true", true);
|
||
// Remove on-disk data for AWG protocol config to avoid stale restore paths.
|
||
$server->executeCommand("rm -rf " . escapeshellarg($configDir) . " 2>/dev/null || true", true);
|
||
$server->executeCommand("rm -rf /opt/amnezia/amnezia-awg 2>/dev/null || true", true);
|
||
|
||
// Clear server deployment metadata in database for this server
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('UPDATE vpn_servers SET vpn_port = NULL, server_public_key = NULL, preshared_key = NULL, awg_params = NULL, status = ?, error_message = NULL WHERE id = ?');
|
||
$stmt->execute(['stopped', $server->getId()]);
|
||
|
||
// Refresh server object data
|
||
$server->refresh();
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => 'Протокол успешно удалён',
|
||
'mode' => 'uninstall'
|
||
];
|
||
} catch (Throwable $e) {
|
||
throw new Exception('Uninstall failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
public static function activate(VpnServer $server, array $protocol, array $options = []): array
|
||
{
|
||
$engine = self::getEngine($protocol);
|
||
$serverId = $server->getId();
|
||
try {
|
||
Logger::appendInstall($serverId, 'Activate start for ' . ($protocol['slug'] ?? 'unknown') . ' engine ' . $engine);
|
||
|
||
// ── Check for existing installation before doing anything destructive ──
|
||
$slug = $protocol['slug'] ?? '';
|
||
$handler = self::resolveHandler($protocol);
|
||
$isAwg = $handler === 'awg';
|
||
$isXray = $handler === 'xray';
|
||
|
||
if ($isAwg) {
|
||
$detection = self::detectBuiltinAwg($server, $protocol);
|
||
Logger::appendInstall($serverId, 'AWG detect result: status=' . ($detection['status'] ?? 'null') . ' message=' . ($detection['message'] ?? 'none'));
|
||
if (($detection['status'] ?? '') === 'existing') {
|
||
Logger::appendInstall($serverId, 'Existing AWG installation detected, restoring instead of reinstalling');
|
||
$restoreResult = self::restoreBuiltinAwg($server, $protocol, $detection, $options);
|
||
// Import existing clients into DB
|
||
self::importExistingAwgClients($server, $protocol, $detection);
|
||
$pdo = DB::conn();
|
||
$pid = self::resolveProtocolId($protocol);
|
||
if ($pid) {
|
||
$details = $detection['details'] ?? [];
|
||
$config = [
|
||
'server_host' => $server->getData()['host'] ?? null,
|
||
'server_port' => $details['vpn_port'] ?? null,
|
||
'extras' => [
|
||
'vpn_port' => $details['vpn_port'] ?? null,
|
||
'server_public_key' => $details['server_public_key'] ?? null,
|
||
'preshared_key' => $details['preshared_key'] ?? null,
|
||
'awg_params' => $details['awg_params'] ?? null,
|
||
]
|
||
];
|
||
$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)]);
|
||
}
|
||
return array_merge($restoreResult, ['mode' => 'restore_existing']);
|
||
}
|
||
}
|
||
|
||
if ($isXray) {
|
||
$xrayDetection = self::detectBuiltinXray($server, $protocol);
|
||
if (($xrayDetection['status'] ?? '') === 'existing') {
|
||
Logger::appendInstall($serverId, 'Existing X-Ray installation detected, restoring instead of reinstalling');
|
||
$restoreResult = self::restoreBuiltinXray($server, $protocol, $xrayDetection, $options);
|
||
return array_merge($restoreResult, ['mode' => 'restore_existing']);
|
||
}
|
||
}
|
||
|
||
// For Cloudflare WARP — always run install script even if WARP binary exists
|
||
// because the script is idempotent and handles redsocks/iptables setup
|
||
if (self::resolveHandler($protocol) === 'warp') {
|
||
$warpDetection = self::detectBuiltinWarp($server, $protocol);
|
||
Logger::appendInstall($serverId, 'WARP detect result: status=' . ($warpDetection['status'] ?? 'null'));
|
||
if (($warpDetection['status'] ?? '') === 'existing') {
|
||
Logger::appendInstall($serverId, 'Existing WARP found, running install script anyway for redsocks/iptables setup');
|
||
// Don't return — fall through to run the install script
|
||
}
|
||
}
|
||
|
||
// ── No existing installation found — proceed with fresh install ──
|
||
|
||
if ($engine === 'builtin_awg') {
|
||
$res = $server->runAwgInstall($options);
|
||
Logger::appendInstall($serverId, 'Builtin AWG install finished');
|
||
|
||
$resolvedPort = null;
|
||
if (isset($res['vpn_port']) && (int) $res['vpn_port'] > 0) {
|
||
$resolvedPort = (int) $res['vpn_port'];
|
||
} elseif (isset($res['server_port']) && (int) $res['server_port'] > 0) {
|
||
$resolvedPort = (int) $res['server_port'];
|
||
}
|
||
|
||
$resolvedAwgParams = $res['awg_params'] ?? null;
|
||
if (!is_array($resolvedAwgParams)) {
|
||
$candidate = [];
|
||
foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4', 'I1', 'I2', 'I3', 'I4', 'I5'] as $k) {
|
||
if (array_key_exists($k, $res)) {
|
||
$candidate[$k] = $res[$k];
|
||
}
|
||
}
|
||
if ($candidate) {
|
||
$resolvedAwgParams = $candidate;
|
||
}
|
||
}
|
||
|
||
$existingProtocol = $server->getData()['install_protocol'] ?? '';
|
||
$currentSlug = $protocol['slug'] ?? '';
|
||
$isFirstProtocol = ($existingProtocol === '' || $existingProtocol === $currentSlug);
|
||
if ($isFirstProtocol) {
|
||
self::markServerActive($serverId, null, [
|
||
'vpn_port' => $resolvedPort,
|
||
'server_public_key' => $res['server_public_key'] ?? null,
|
||
'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();
|
||
$pid = self::resolveProtocolId($protocol);
|
||
if ($pid) {
|
||
$config = [
|
||
'server_host' => $server->getData()['host'] ?? null,
|
||
'server_port' => $resolvedPort,
|
||
'extras' => $res
|
||
];
|
||
$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)]);
|
||
}
|
||
// Sync existing clients from DB to Container (Restore active clients)
|
||
self::syncClientsToContainer($server, $protocol);
|
||
return ['success' => true, 'mode' => 'install', 'details' => $res];
|
||
}
|
||
if (!isset($options['server_port']) || !is_int($options['server_port'])) {
|
||
$options['server_port'] = self::chooseServerPort($server, $protocol['definition']['metadata'] ?? []);
|
||
}
|
||
$res = self::runScript($server, $protocol, 'install', $options);
|
||
if (!isset($res['success'])) {
|
||
$res['success'] = true;
|
||
}
|
||
$port = null;
|
||
$password = null;
|
||
$clientId = null;
|
||
if (isset($res['vpn_port'])) {
|
||
$port = (int) $res['vpn_port'];
|
||
}
|
||
if (isset($res['server_port'])) {
|
||
$port = (int) $res['server_port'];
|
||
}
|
||
if (isset($res['client_id']) && is_string($res['client_id'])) {
|
||
$clientId = $res['client_id'];
|
||
}
|
||
if (is_string($res['output'] ?? '')) {
|
||
$out = $res['output'];
|
||
if (preg_match('/Port:\s*(\d+)/i', $out, $m)) {
|
||
$port = (int) $m[1];
|
||
}
|
||
if (preg_match('/Password:\s*([\w-]+)/i', $out, $m)) {
|
||
$password = $m[1];
|
||
}
|
||
if (preg_match('/ClientID:\s*([0-9a-fA-F-]+)/i', $out, $m)) {
|
||
$clientId = $m[1];
|
||
}
|
||
}
|
||
if (($protocol['slug'] ?? '') === 'xray-vless' && $clientId === null) {
|
||
$containerName = 'amnezia-xray';
|
||
if (isset($res['container_name']) && is_string($res['container_name']) && trim($res['container_name']) !== '') {
|
||
$containerName = trim($res['container_name']);
|
||
}
|
||
try {
|
||
$cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null", true);
|
||
if (trim((string) $cfg) === '') {
|
||
$cfg = $server->executeCommand("docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null", true);
|
||
}
|
||
$decoded = json_decode(trim((string) $cfg), true);
|
||
if (is_array($decoded)) {
|
||
$inbounds = $decoded['inbounds'] ?? [];
|
||
if (is_array($inbounds) && !empty($inbounds)) {
|
||
$settings = $inbounds[0]['settings'] ?? [];
|
||
$clients = $settings['clients'] ?? [];
|
||
if (is_array($clients) && !empty($clients)) {
|
||
$cid = $clients[0]['id'] ?? null;
|
||
if (is_string($cid) && $cid !== '') {
|
||
$clientId = $cid;
|
||
}
|
||
}
|
||
$stream = $inbounds[0]['streamSettings'] ?? [];
|
||
if (is_array($stream) && ($stream['security'] ?? '') === 'reality') {
|
||
$rs = $stream['realitySettings'] ?? [];
|
||
$serverNames = $rs['serverNames'] ?? ($rs['serverName'] ?? []);
|
||
$shortIds = $rs['shortIds'] ?? ($rs['shortId'] ?? []);
|
||
$serverName = is_array($serverNames) ? ($serverNames[0] ?? null) : (is_string($serverNames) ? $serverNames : null);
|
||
$shortId = is_array($shortIds) ? ($shortIds[0] ?? null) : (is_string($shortIds) ? $shortIds : null);
|
||
$privateKey = $rs['privateKey'] ?? null;
|
||
$publicKey = null;
|
||
if (is_string($privateKey) && $privateKey !== '' && function_exists('sodium_crypto_scalarmult_base')) {
|
||
$pk = $privateKey;
|
||
$b64 = strtr($pk, '-_', '+/');
|
||
$bin = base64_decode($b64, true);
|
||
if ($bin === false) {
|
||
$bin = base64_decode($pk, true);
|
||
}
|
||
if (is_string($bin) && strlen($bin) === 32) {
|
||
$pub = sodium_crypto_scalarmult_base($bin);
|
||
$publicKey = rtrim(strtr(base64_encode($pub), '+/', '-_'), '=');
|
||
}
|
||
}
|
||
if ($publicKey) {
|
||
$res['reality_public_key'] = $publicKey;
|
||
}
|
||
// Store private key for future restoration
|
||
if (is_string($privateKey) && $privateKey !== '') {
|
||
$res['reality_private_key'] = $privateKey;
|
||
}
|
||
if ($shortId) {
|
||
$res['reality_short_id'] = $shortId;
|
||
}
|
||
if ($serverName) {
|
||
$res['reality_server_name'] = $serverName;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
}
|
||
}
|
||
Logger::appendInstall($serverId, 'Scripted install parsed port ' . ($port ?? 0) . ' password ' . ($password ?? ''));
|
||
$pdo = DB::conn();
|
||
$pid = self::resolveProtocolId($protocol);
|
||
if ($pid) {
|
||
$config = [
|
||
'server_host' => $server->getData()['host'] ?? null,
|
||
'server_port' => $port,
|
||
'extras' => [
|
||
'password' => $password,
|
||
'client_id' => $clientId,
|
||
'result' => $res,
|
||
'reality_public_key' => $res['reality_public_key'] ?? null,
|
||
'reality_private_key' => $res['reality_private_key'] ?? null,
|
||
'reality_short_id' => $res['reality_short_id'] ?? null,
|
||
'reality_server_name' => $res['reality_server_name'] ?? null,
|
||
]
|
||
];
|
||
$stmt2 = $pdo->prepare('INSERT INTO server_protocols (server_id, protocol_id, config_data, applied_at, created_at) VALUES (?, ?, ?, NOW(), NOW()) ON DUPLICATE KEY UPDATE config_data = VALUES(config_data), applied_at = NOW()');
|
||
$stmt2->execute([$serverId, $pid, json_encode($config)]);
|
||
}
|
||
// Save vpn_port to vpn_servers table ONLY for the primary (first) protocol
|
||
// Secondary protocols store their ports in server_protocols.config_data only
|
||
if ($port !== null && $port > 0) {
|
||
$existingProtocol = $server->getData()['install_protocol'] ?? '';
|
||
$currentSlug = $protocol['slug'] ?? '';
|
||
$isFirstProtocol = ($existingProtocol === '' || $existingProtocol === $currentSlug);
|
||
if ($isFirstProtocol) {
|
||
self::markServerActive($serverId, null, ['vpn_port' => $port]);
|
||
}
|
||
}
|
||
|
||
// ── WARP: Auto-patch X-Ray outbound to route through WARP ──
|
||
if (self::resolveHandler($protocol) === 'warp') {
|
||
self::patchXrayForWarp($server);
|
||
}
|
||
|
||
return $res;
|
||
} catch (Throwable $e) {
|
||
$message = (string) $e->getMessage();
|
||
if (
|
||
stripos($message, 'server_protocols_ibfk_1') !== false
|
||
|| (stripos($message, 'foreign key constraint fails') !== false && stripos($message, 'server_protocols') !== false)
|
||
) {
|
||
$message = 'Сервер был удален или пересоздан во время установки. Обновите страницу и запустите установку заново.';
|
||
}
|
||
|
||
self::markServerError($serverId, $message);
|
||
Logger::appendInstall($serverId, 'Activate failed: ' . $message);
|
||
throw new Exception($message, 0, $e);
|
||
}
|
||
}
|
||
|
||
private static function runBuiltinAivpnAddClient(VpnServer $server, array $options): array
|
||
{
|
||
$serverData = $server->getData();
|
||
$containerName = self::resolveAivpnContainerName($server, $options);
|
||
|
||
$clientName = trim((string) ($options['login'] ?? ($options['name'] ?? '')));
|
||
if ($clientName === '') {
|
||
$clientName = 'client-' . date('YmdHis');
|
||
}
|
||
|
||
$serverHostRaw = trim((string) ($options['server_host'] ?? ($serverData['host'] ?? '')));
|
||
$serverHostSanitized = preg_replace('#^https?://#i', '', $serverHostRaw);
|
||
$serverHostSanitized = preg_replace('#/.*$#', '', $serverHostSanitized ?? '');
|
||
$serverHost = $serverHostSanitized;
|
||
$embeddedPort = null;
|
||
if ($serverHostSanitized !== '' && preg_match('/^(.+?)(?::\d+)+$/', $serverHostSanitized, $m)) {
|
||
$serverHost = trim((string) $m[1]);
|
||
if (preg_match('/:(\d+)$/', $serverHostSanitized, $pm)) {
|
||
$embeddedPort = (int) $pm[1];
|
||
}
|
||
}
|
||
|
||
$defaultPort = 443;
|
||
if (stripos((string) ($serverData['install_protocol'] ?? ''), 'aivpn') !== false && (int) ($serverData['vpn_port'] ?? 0) > 0) {
|
||
$defaultPort = (int) $serverData['vpn_port'];
|
||
}
|
||
$serverPort = isset($options['server_port']) ? (int) $options['server_port'] : 0;
|
||
if ($serverPort <= 0 && $embeddedPort !== null && $embeddedPort > 0) {
|
||
$serverPort = $embeddedPort;
|
||
}
|
||
if ($serverPort <= 0) {
|
||
$serverPort = $defaultPort;
|
||
}
|
||
if (
|
||
stripos((string) ($serverData['install_protocol'] ?? ''), 'aivpn') === false &&
|
||
$embeddedPort === null &&
|
||
(int) ($serverData['vpn_port'] ?? 0) > 0 &&
|
||
$serverPort === (int) $serverData['vpn_port']
|
||
) {
|
||
$serverPort = 443;
|
||
}
|
||
if ($serverPort <= 0) {
|
||
$serverPort = 443;
|
||
}
|
||
|
||
// Use full path to aivpn-server binary as per official Dockerfile
|
||
// The binary is installed to /usr/local/bin/aivpn-server in the container
|
||
$binaryCmd = '/usr/local/bin/aivpn-server';
|
||
|
||
// Verify the binary exists, fallback to other locations if needed
|
||
// Use auto-detection for sudo requirement (null = auto-detect for docker commands)
|
||
$checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
|
||
escapeshellarg($containerName),
|
||
escapeshellarg($binaryCmd));
|
||
$checkResult = (string) $server->executeCommand($checkCmd, null);
|
||
if (strpos($checkResult, 'found') === false) {
|
||
// Try alternative locations
|
||
$fallbacks = [
|
||
'aivpn-server', // In PATH
|
||
'/usr/bin/aivpn-server',
|
||
'/opt/aivpn/aivpn-server',
|
||
'/app/aivpn-server',
|
||
];
|
||
foreach ($fallbacks as $loc) {
|
||
$checkCmd = sprintf('docker exec -i %s test -f %s && echo "found" || echo "not found"',
|
||
escapeshellarg($containerName),
|
||
escapeshellarg($loc));
|
||
$checkResult = (string) $server->executeCommand($checkCmd, null);
|
||
if (strpos($checkResult, 'found') !== false) {
|
||
$binaryCmd = $loc;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
$cmdParts = [
|
||
'docker',
|
||
'exec',
|
||
'-i',
|
||
escapeshellarg($containerName),
|
||
$binaryCmd,
|
||
'--add-client',
|
||
escapeshellarg($clientName),
|
||
'--key-file',
|
||
'/etc/aivpn/server.key',
|
||
'--clients-db',
|
||
'/etc/aivpn/clients.json',
|
||
];
|
||
|
||
if ($serverHost !== '') {
|
||
$cmdParts[] = '--server-ip';
|
||
$cmdParts[] = escapeshellarg($serverHost . ':' . $serverPort);
|
||
}
|
||
|
||
$cmd = implode(' ', $cmdParts);
|
||
Logger::appendInstall($server->getId(), 'Adding AIVPN client via builtin add_client: ' . $clientName . ' in ' . $containerName);
|
||
|
||
try {
|
||
// Use auto-detection for sudo requirement (null = auto-detect for docker commands)
|
||
$output = (string) $server->executeCommand($cmd, null);
|
||
} catch (Exception $e) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN add_client docker exec failed: ' . $e->getMessage());
|
||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||
if ($hostResult !== null) {
|
||
return $hostResult;
|
||
}
|
||
return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
|
||
}
|
||
|
||
$trimmedOutput = trim($output);
|
||
if ($trimmedOutput === '' ||
|
||
stripos($trimmedOutput, 'Error response from daemon') !== false ||
|
||
stripos($trimmedOutput, 'is restarting') !== false ||
|
||
stripos($trimmedOutput, 'No such container') !== false ||
|
||
stripos($trimmedOutput, 'executable file not found') !== false) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN add_client container unavailable, trying host binary fallback');
|
||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||
if ($hostResult !== null) {
|
||
return $hostResult;
|
||
}
|
||
return ['success' => true, 'connection_key' => '', 'connection_uri' => ''];
|
||
}
|
||
|
||
if (stripos($trimmedOutput, 'error') !== false || stripos($trimmedOutput, 'failed') !== false) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN add_client returned error: ' . substr($trimmedOutput, 0, 200));
|
||
$hostResult = self::runAivpnAddClientViaHostBinary($server, $clientName, $serverHost, $serverPort);
|
||
if ($hostResult !== null) {
|
||
return $hostResult;
|
||
}
|
||
return ['success' => false, 'error' => $trimmedOutput];
|
||
}
|
||
|
||
$parsed = self::parseAivpnAddClientOutput($output);
|
||
|
||
if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) {
|
||
$head = substr(str_replace(["\r", "\n"], ' ', $trimmedOutput), 0, 220);
|
||
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];
|
||
if (!empty($parsed['connection_uri'])) {
|
||
$result['connection_uri'] = $parsed['connection_uri'];
|
||
}
|
||
if (!empty($parsed['connection_key'])) {
|
||
$result['connection_key'] = $parsed['connection_key'];
|
||
}
|
||
if (!empty($parsed['client_ip'])) {
|
||
$result['client_ip'] = $parsed['client_ip'];
|
||
}
|
||
if (!empty($parsed['client_id'])) {
|
||
$result['client_id'] = $parsed['client_id'];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private static function parseAivpnAddClientOutput(string $output): array
|
||
{
|
||
$result = [];
|
||
$trimmed = trim($output);
|
||
if ($trimmed === '') {
|
||
return $result;
|
||
}
|
||
|
||
if (preg_match('/(aivpn:\/\/[A-Za-z0-9_\-+=\/]+)/', $trimmed, $m)) {
|
||
$uri = trim((string) $m[1]);
|
||
$result['connection_uri'] = $uri;
|
||
if (stripos($uri, 'aivpn://') === 0) {
|
||
$result['connection_key'] = substr($uri, strlen('aivpn://'));
|
||
}
|
||
}
|
||
|
||
if (preg_match('/\bID:\s*([a-zA-Z0-9]+)/', $trimmed, $m)) {
|
||
$result['client_id'] = trim((string) $m[1]);
|
||
}
|
||
|
||
if (preg_match('/\bVPN\s*IP:\s*([0-9.]+)/i', $trimmed, $m)) {
|
||
$result['client_ip'] = trim((string) $m[1]);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private static function runAivpnAddClientViaHostBinary(VpnServer $server, string $clientName, string $serverHost, int $serverPort): ?array
|
||
{
|
||
$hostBinaryPaths = [
|
||
'/opt/amnezia/aivpn/aivpn-server-linux-x86_64',
|
||
'/opt/amnezia/aivpn/aivpn-server',
|
||
'/usr/local/bin/aivpn-server',
|
||
'/usr/bin/aivpn-server',
|
||
];
|
||
|
||
$binaryPath = null;
|
||
foreach ($hostBinaryPaths as $path) {
|
||
try {
|
||
$check = (string) $server->executeCommand('test -f ' . escapeshellarg($path) . ' && echo "found" || echo "not_found"', true);
|
||
if (trim($check) === 'found') {
|
||
$binaryPath = $path;
|
||
break;
|
||
}
|
||
} catch (Exception $e) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if ($binaryPath === null) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN host binary not found for fallback');
|
||
return null;
|
||
}
|
||
|
||
$cmdParts = [
|
||
escapeshellarg($binaryPath),
|
||
'--add-client',
|
||
escapeshellarg($clientName),
|
||
'--key-file',
|
||
escapeshellarg('/etc/aivpn/server.key'),
|
||
'--clients-db',
|
||
escapeshellarg('/etc/aivpn/clients.json'),
|
||
];
|
||
|
||
if ($serverHost !== '') {
|
||
$cmdParts[] = '--server-ip';
|
||
$cmdParts[] = escapeshellarg($serverHost . ':' . $serverPort);
|
||
}
|
||
|
||
$cmd = implode(' ', $cmdParts);
|
||
Logger::appendInstall($server->getId(), 'AIVPN add_client fallback via host binary: ' . $clientName);
|
||
|
||
try {
|
||
$output = (string) $server->executeCommand($cmd, true);
|
||
} catch (Exception $e) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN host binary fallback failed: ' . $e->getMessage());
|
||
return null;
|
||
}
|
||
|
||
$trimmedOutput = trim($output);
|
||
if ($trimmedOutput === '' ||
|
||
stripos($trimmedOutput, 'Failed to add client') !== false ||
|
||
stripos($trimmedOutput, 'error') !== false) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN host binary fallback returned error: ' . substr($trimmedOutput, 0, 200));
|
||
return null;
|
||
}
|
||
|
||
$parsed = self::parseAivpnAddClientOutput($output);
|
||
if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) {
|
||
Logger::appendInstall($server->getId(), 'AIVPN host binary fallback produced no connection key');
|
||
return null;
|
||
}
|
||
|
||
$result = ['success' => true];
|
||
if (!empty($parsed['connection_uri'])) {
|
||
$result['connection_uri'] = $parsed['connection_uri'];
|
||
}
|
||
if (!empty($parsed['connection_key'])) {
|
||
$result['connection_key'] = $parsed['connection_key'];
|
||
}
|
||
if (!empty($parsed['client_ip'])) {
|
||
$result['client_ip'] = $parsed['client_ip'];
|
||
}
|
||
if (!empty($parsed['client_id'])) {
|
||
$result['client_id'] = $parsed['client_id'];
|
||
}
|
||
|
||
Logger::appendInstall($server->getId(), 'AIVPN host binary fallback succeeded for ' . $clientName);
|
||
return $result;
|
||
}
|
||
|
||
private static function runBuiltinXrayAddClient(VpnServer $server, array $options): array
|
||
{
|
||
$clientId = $options['client_id'] ?? null;
|
||
if (!$clientId) {
|
||
throw new Exception("Client ID is required for X-Ray add_client");
|
||
}
|
||
|
||
// Default container name if not provided
|
||
$containerName = 'amnezia-xray';
|
||
if (!empty($options['container_name'])) {
|
||
$containerName = $options['container_name'];
|
||
}
|
||
|
||
Logger::appendInstall($server->getId(), "Adding X-Ray client $clientId to container $containerName");
|
||
|
||
// 1. Read config
|
||
$catCmd = "docker exec -i " . escapeshellarg($containerName) . " cat /opt/amnezia/xray/server.json 2>/dev/null";
|
||
$configRaw = $server->executeCommand($catCmd, true);
|
||
|
||
if (trim($configRaw) === '') {
|
||
$catCmd = "docker exec -i " . escapeshellarg($containerName) . " cat /etc/xray/config.json 2>/dev/null";
|
||
$configRaw = $server->executeCommand($catCmd, true);
|
||
}
|
||
|
||
if (trim($configRaw) === '') {
|
||
throw new Exception("Could not read X-Ray config from $containerName");
|
||
}
|
||
|
||
$config = json_decode($configRaw, true);
|
||
if (!$config) {
|
||
throw new Exception("Invalid JSON in X-Ray config");
|
||
}
|
||
|
||
// 2. Modify config
|
||
|
||
// Ensure policy for 1 user 1 connection
|
||
if (!isset($config['policy'])) {
|
||
$config['policy'] = ['levels' => ['0' => []]];
|
||
}
|
||
if (!isset($config['policy']['levels'])) {
|
||
$config['policy']['levels'] = ['0' => []];
|
||
}
|
||
if (!isset($config['policy']['levels']['0'])) {
|
||
$config['policy']['levels']['0'] = [];
|
||
}
|
||
|
||
// Enforce stats and online tracking for user level 0
|
||
$config['policy']['levels']['0']['handshake'] = 4;
|
||
$config['policy']['levels']['0']['connIdle'] = 300;
|
||
$config['policy']['levels']['0']['uplinkOnly'] = 2;
|
||
$config['policy']['levels']['0']['downlinkOnly'] = 5;
|
||
$config['policy']['levels']['0']['statsUserUplink'] = true;
|
||
$config['policy']['levels']['0']['statsUserDownlink'] = true;
|
||
$config['policy']['levels']['0']['statsUserOnline'] = true; // Enable online tracking
|
||
$config['policy']['levels']['0']['bufferSize'] = 4;
|
||
|
||
// Ensure API services include StatsService and RoutingService
|
||
if (!isset($config['api'])) {
|
||
$config['api'] = ['tag' => 'api', 'services' => []];
|
||
}
|
||
if (!isset($config['api']['services'])) {
|
||
$config['api']['services'] = [];
|
||
}
|
||
if (!in_array('StatsService', $config['api']['services'])) {
|
||
$config['api']['services'][] = 'StatsService';
|
||
}
|
||
if (!in_array('RoutingService', $config['api']['services'])) {
|
||
$config['api']['services'][] = 'RoutingService';
|
||
}
|
||
|
||
// Ensure blocked outbound exists for IP blocking
|
||
if (!isset($config['outbounds'])) {
|
||
$config['outbounds'] = [];
|
||
}
|
||
$hasBlocked = false;
|
||
foreach ($config['outbounds'] as $ob) {
|
||
if (($ob['tag'] ?? '') === 'blocked') {
|
||
$hasBlocked = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!$hasBlocked) {
|
||
$config['outbounds'][] = ['protocol' => 'blackhole', 'tag' => 'blocked'];
|
||
}
|
||
|
||
// Ensure main inbound has a tag for routing rules
|
||
if (!isset($config['inbounds'][0]['tag'])) {
|
||
$config['inbounds'][0]['tag'] = 'vless-in';
|
||
}
|
||
|
||
// Assuming VLESS structure: inbounds[0] -> settings -> clients
|
||
|
||
if (!isset($config['inbounds'][0]['settings']['clients'])) {
|
||
// Might be different structure? But we stick to standard Amnezia XRay config
|
||
if (!isset($config['inbounds'][0]['settings'])) {
|
||
$config['inbounds'][0]['settings'] = [];
|
||
}
|
||
if (!isset($config['inbounds'][0]['settings']['clients'])) {
|
||
$config['inbounds'][0]['settings']['clients'] = [];
|
||
}
|
||
}
|
||
|
||
// Check if client exists
|
||
$clients = &$config['inbounds'][0]['settings']['clients'];
|
||
$duplicateFound = false;
|
||
foreach ($clients as $k => $c) {
|
||
if (($c['id'] ?? '') === $clientId) {
|
||
// Already exists by ID (exact match)
|
||
Logger::appendInstall($server->getId(), "Client $clientId already exists in X-Ray config");
|
||
return ['success' => true, 'message' => 'Client already exists'];
|
||
}
|
||
if (($c['email'] ?? '') === (!empty($options['login']) ? $options['login'] : $clientId)) {
|
||
// Email conflict! (Different ID but same email)
|
||
// This happens if user re-adds a client with same login but new UUID (after deleting from DB)
|
||
Logger::appendInstall($server->getId(), "Client email already exists in X-Ray config. Updating ID/Level.");
|
||
|
||
// Update existing client entry with new UUID
|
||
$clients[$k]['id'] = $clientId;
|
||
$clients[$k]['level'] = 0; // Ensure level 0
|
||
|
||
$duplicateFound = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!$duplicateFound) {
|
||
// Add new client (no conflict)
|
||
$email = !empty($options['login']) ? $options['login'] : $clientId;
|
||
$newClient = ['id' => $clientId, 'email' => $email];
|
||
|
||
// Detect flow from other clients or default
|
||
$flow = 'xtls-rprx-vision'; // Default for Reality
|
||
if (!empty($clients)) {
|
||
if (isset($clients[0]['flow'])) {
|
||
$flow = $clients[0]['flow'];
|
||
}
|
||
}
|
||
$newClient['flow'] = $flow;
|
||
$newClient['level'] = 0; // Explicitly set level 0
|
||
|
||
$clients[] = $newClient;
|
||
}
|
||
|
||
// Fix JSON encoding issues (empty objects becoming arrays)
|
||
if (isset($config['stats']) && empty($config['stats'])) {
|
||
$config['stats'] = new stdClass();
|
||
}
|
||
if (isset($config['policy']['levels']) && is_array($config['policy']['levels'])) {
|
||
// Check if it's an indexed array (0, 1...) which is wrong for X-ray levels map
|
||
if (array_keys($config['policy']['levels']) === range(0, count($config['policy']['levels']) - 1)) {
|
||
$newLevels = new stdClass();
|
||
foreach ($config['policy']['levels'] as $idx => $lvl) {
|
||
$newLevels->{(string) $idx} = $lvl;
|
||
}
|
||
$config['policy']['levels'] = $newLevels;
|
||
} elseif (empty($config['policy']['levels'])) {
|
||
$config['policy']['levels'] = new stdClass();
|
||
}
|
||
} else {
|
||
if (!isset($config['policy'])) {
|
||
$config['policy'] = new stdClass();
|
||
}
|
||
if (!isset($config['policy']['levels'])) {
|
||
$config['policy']['levels'] = new stdClass();
|
||
}
|
||
}
|
||
|
||
// Enforce Level 0 Policy with online tracking
|
||
if (!isset($config['policy']['levels']->{'0'})) {
|
||
$config['policy']['levels']->{'0'} = new stdClass();
|
||
}
|
||
$level0 = $config['policy']['levels']->{'0'};
|
||
// Cast to object if array
|
||
if (is_array($level0)) {
|
||
$level0 = (object) $level0;
|
||
$config['policy']['levels']->{'0'} = $level0;
|
||
}
|
||
|
||
// Set restriction parameters (statsUserOnline enables connection counting)
|
||
$level0->handshake = 4;
|
||
$level0->connIdle = 300;
|
||
$level0->uplinkOnly = 2;
|
||
$level0->downlinkOnly = 5;
|
||
$level0->statsUserUplink = true;
|
||
$level0->statsUserDownlink = true;
|
||
$level0->statsUserOnline = true; // Enable online tracking for enforcement
|
||
$level0->bufferSize = 4;
|
||
// It's an assoc array, duplicate it to stdClass to ensure object encoding
|
||
$config['policy']['levels'] = (object) $config['policy']['levels'];
|
||
|
||
|
||
// 3. Write config back
|
||
$newJson = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||
$b64 = base64_encode($newJson);
|
||
$writeCmd = "docker exec -i " . escapeshellarg($containerName) . " sh -c 'echo \"$b64\" | base64 -d > /opt/amnezia/xray/server.json'";
|
||
|
||
$server->executeCommand($writeCmd, true);
|
||
|
||
// 4. Restart container
|
||
$server->executeCommand("docker restart " . escapeshellarg($containerName), true);
|
||
|
||
Logger::appendInstall($server->getId(), "Updated X-Ray config and restarted container");
|
||
|
||
return ['success' => true];
|
||
}
|
||
|
||
/**
|
||
* Sync all active clients from DB to the Container configuration
|
||
*/
|
||
private static function syncClientsToContainer(VpnServer $server, array $protocol): void
|
||
{
|
||
$serverId = $server->getId();
|
||
$pdo = DB::conn();
|
||
|
||
// Fetch active clients
|
||
$stmt = $pdo->prepare("SELECT * FROM vpn_clients WHERE server_id = ? AND status = 'active'");
|
||
$stmt->execute([$serverId]);
|
||
$clients = $stmt->fetchAll();
|
||
|
||
if (empty($clients)) {
|
||
return;
|
||
}
|
||
|
||
$serverData = $server->getData();
|
||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||
$containerName = $metadata['container_name'] ?? $serverData['container_name'] ?? 'amnezia-awg';
|
||
// AWG2: try awg0.conf first (standard), fall back to wg0.conf (legacy)
|
||
$isAwg2 = (stripos($containerName, 'awg2') !== false || ($protocol['slug'] ?? '') === 'awg2');
|
||
$configDir = '/opt/amnezia/awg';
|
||
$configFile = $isAwg2 ? 'awg0.conf' : 'wg0.conf';
|
||
$conf = $server->executeCommand("docker exec -i $containerName cat {$configDir}/{$configFile}", true);
|
||
if ($isAwg2 && (!$conf || strpos($conf, '[Interface]') === false)) {
|
||
$configFile = 'wg0.conf';
|
||
$conf = $server->executeCommand("docker exec -i $containerName cat {$configDir}/{$configFile}", true);
|
||
}
|
||
if (!$conf)
|
||
return;
|
||
|
||
$newPeersBlock = "";
|
||
$count = 0;
|
||
|
||
foreach ($clients as $client) {
|
||
$ip = $client['client_ip'];
|
||
// Check if peer already exists (simple check by IP)
|
||
if (strpos($conf, $ip) !== false) {
|
||
continue;
|
||
}
|
||
|
||
// Append Peer
|
||
$newPeersBlock .= "\n[Peer]\n";
|
||
$newPeersBlock .= "PublicKey = " . $client['public_key'] . "\n";
|
||
if (!empty($client['preshared_key'])) {
|
||
$newPeersBlock .= "PresharedKey = " . $client['preshared_key'] . "\n";
|
||
}
|
||
// Use AllowedIPs from DB or default to /32
|
||
$allowed = $client['allowed_ips'] ?? "$ip/32";
|
||
$newPeersBlock .= "AllowedIPs = $allowed\n";
|
||
$count++;
|
||
}
|
||
|
||
if ($count > 0) {
|
||
Logger::appendInstall($serverId, "Syncing $count existing clients to server config");
|
||
$conf .= $newPeersBlock;
|
||
$escaped = addslashes($conf);
|
||
$server->executeCommand("docker exec -i $containerName sh -c 'echo \"$escaped\" > {$configDir}/{$configFile}'", true);
|
||
|
||
// Reload interface
|
||
$server->executeCommand("docker exec -i $containerName wg-quick down wg0 || true", true);
|
||
$server->executeCommand("docker exec -i $containerName wg-quick up wg0", true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve protocol ID from protocol array, looking up by slug if needed
|
||
*/
|
||
private static function resolveProtocolId(array $protocol): int
|
||
{
|
||
$pid = (int) ($protocol['id'] ?? 0);
|
||
if (!$pid) {
|
||
$slug = $protocol['slug'] ?? '';
|
||
if ($slug === '') {
|
||
return 0;
|
||
}
|
||
try {
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('SELECT id FROM protocols WHERE slug = ? LIMIT 1');
|
||
$stmt->execute([$slug]);
|
||
$pid = (int) $stmt->fetchColumn();
|
||
} catch (Throwable $e) {
|
||
return 0;
|
||
}
|
||
}
|
||
return $pid;
|
||
}
|
||
|
||
/**
|
||
* Detect existing X-Ray (VLESS Reality) installation on the server
|
||
*/
|
||
private static function detectBuiltinXray(VpnServer $server, array $protocol): array
|
||
{
|
||
$metadata = $protocol['definition']['metadata'] ?? [];
|
||
$containerName = $metadata['container_name'] ?? 'amnezia-xray';
|
||
$containerFilter = escapeshellarg('^' . $containerName . '$');
|
||
$containerArg = escapeshellarg($containerName);
|
||
|
||
$containerListRaw = trim($server->executeCommand("docker ps -a --filter name={$containerFilter} --format '{{.Names}}'", true));
|
||
if ($containerListRaw === '') {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Контейнер X-Ray не найден на сервере'
|
||
];
|
||
}
|
||
|
||
if (preg_match('/docker: command not found|command not found|cannot connect to the docker daemon|permission denied/i', $containerListRaw)) {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Docker CLI недоступен на сервере',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerListRaw,
|
||
]
|
||
];
|
||
}
|
||
|
||
$containerNames = array_values(array_filter(array_map('trim', preg_split('/\R+/', $containerListRaw))));
|
||
if (!in_array($containerName, $containerNames, true)) {
|
||
return [
|
||
'status' => 'absent',
|
||
'message' => 'Контейнер X-Ray не найден на сервере'
|
||
];
|
||
}
|
||
|
||
$containerState = trim($server->executeCommand("docker inspect --format '{{.State.Status}}' {$containerArg}", true));
|
||
|
||
// Read X-Ray config
|
||
$configRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/xray/server.json 2>/dev/null", true);
|
||
if (trim($configRaw) === '') {
|
||
$configRaw = $server->executeCommand("docker exec -i {$containerArg} cat /etc/xray/config.json 2>/dev/null", true);
|
||
}
|
||
|
||
if (trim($configRaw) === '') {
|
||
return [
|
||
'status' => 'partial',
|
||
'message' => 'Контейнер X-Ray найден, но конфигурация server.json отсутствует',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
]
|
||
];
|
||
}
|
||
|
||
$config = json_decode(trim($configRaw), true);
|
||
if (!is_array($config)) {
|
||
return [
|
||
'status' => 'partial',
|
||
'message' => 'Не удалось разобрать JSON конфигурации X-Ray',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
]
|
||
];
|
||
}
|
||
|
||
// Extract port, clients, Reality keys
|
||
$inbounds = $config['inbounds'] ?? [];
|
||
$port = 443;
|
||
$xrayClients = [];
|
||
$realityPublicKey = null;
|
||
$realityPrivateKey = null;
|
||
$realityShortId = null;
|
||
$realityServerName = null;
|
||
|
||
if (is_array($inbounds) && !empty($inbounds)) {
|
||
$port = (int) ($inbounds[0]['port'] ?? 443);
|
||
$settings = $inbounds[0]['settings'] ?? [];
|
||
$xrayClients = $settings['clients'] ?? [];
|
||
|
||
$stream = $inbounds[0]['streamSettings'] ?? [];
|
||
if (is_array($stream) && ($stream['security'] ?? '') === 'reality') {
|
||
$rs = $stream['realitySettings'] ?? [];
|
||
$serverNames = $rs['serverNames'] ?? ($rs['serverName'] ?? []);
|
||
$shortIds = $rs['shortIds'] ?? ($rs['shortId'] ?? []);
|
||
$realityServerName = is_array($serverNames) ? ($serverNames[0] ?? null) : (is_string($serverNames) ? $serverNames : null);
|
||
$realityShortId = is_array($shortIds) ? ($shortIds[0] ?? null) : (is_string($shortIds) ? $shortIds : null);
|
||
$realityPrivateKey = $rs['privateKey'] ?? null;
|
||
|
||
// Derive public key from private
|
||
if (is_string($realityPrivateKey) && $realityPrivateKey !== '' && function_exists('sodium_crypto_scalarmult_base')) {
|
||
$b64 = strtr($realityPrivateKey, '-_', '+/');
|
||
$bin = base64_decode($b64, true);
|
||
if ($bin === false) {
|
||
$bin = base64_decode($realityPrivateKey, true);
|
||
}
|
||
if (is_string($bin) && strlen($bin) === 32) {
|
||
$pub = sodium_crypto_scalarmult_base($bin);
|
||
$realityPublicKey = rtrim(strtr(base64_encode($pub), '+/', '-_'), '=');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Read clientsTable for names
|
||
$clientsTableRaw = $server->executeCommand("docker exec -i {$containerArg} cat /opt/amnezia/xray/clientsTable 2>/dev/null", true);
|
||
$clientsTable = json_decode(trim($clientsTableRaw), true);
|
||
$clientsCount = is_array($xrayClients) ? count($xrayClients) : 0;
|
||
|
||
return [
|
||
'status' => 'existing',
|
||
'message' => 'Найдена установленная конфигурация X-Ray VLESS Reality',
|
||
'details' => [
|
||
'container_name' => $containerName,
|
||
'container_status' => $containerState,
|
||
'port' => $port,
|
||
'clients' => $xrayClients,
|
||
'clients_table' => is_array($clientsTable) ? $clientsTable : [],
|
||
'clients_count' => $clientsCount,
|
||
'reality_public_key' => $realityPublicKey,
|
||
'reality_private_key' => $realityPrivateKey,
|
||
'reality_short_id' => $realityShortId,
|
||
'reality_server_name' => $realityServerName,
|
||
'config' => $config,
|
||
'summary' => sprintf('Container %s (%s), port %d, clients %d', $containerName, $containerState ?: 'unknown', $port, $clientsCount)
|
||
]
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Restore existing X-Ray installation: save config to DB, import clients
|
||
*/
|
||
private static function restoreBuiltinXray(VpnServer $server, array $protocol, array $detection, array $options): array
|
||
{
|
||
$details = $detection['details'] ?? [];
|
||
$containerName = $details['container_name'] ?? 'amnezia-xray';
|
||
$containerArg = escapeshellarg($containerName);
|
||
$serverId = $server->getId();
|
||
|
||
// Ensure container is running
|
||
$server->executeCommand("docker start {$containerArg} 2>/dev/null || true", true);
|
||
|
||
// Update vpn_servers with X-Ray data
|
||
$port = $details['port'] ?? 443;
|
||
$pdo = DB::conn();
|
||
$stmt = $pdo->prepare('
|
||
UPDATE vpn_servers
|
||
SET vpn_port = ?,
|
||
status = ?,
|
||
error_message = NULL,
|
||
deployed_at = COALESCE(deployed_at, NOW())
|
||
WHERE id = ?
|
||
');
|
||
$stmt->execute([$port, 'active', $serverId]);
|
||
$server->refresh();
|
||
|
||
// Save protocol binding
|
||
$pid = self::resolveProtocolId($protocol);
|
||
if ($pid) {
|
||
$config = [
|
||
'server_host' => $server->getData()['host'] ?? null,
|
||
'server_port' => $port,
|
||
'extras' => [
|
||
'reality_public_key' => $details['reality_public_key'] ?? null,
|
||
'reality_private_key' => $details['reality_private_key'] ?? null,
|
||
'reality_short_id' => $details['reality_short_id'] ?? null,
|
||
'reality_server_name' => $details['reality_server_name'] ?? null,
|
||
'container_name' => $containerName,
|
||
]
|
||
];
|
||
$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)]);
|
||
}
|
||
|
||
// Import X-Ray clients into database
|
||
$xrayClients = $details['clients'] ?? [];
|
||
$clientsTable = $details['clients_table'] ?? [];
|
||
$serverData = $server->getData();
|
||
$imported = 0;
|
||
|
||
// Build name lookup from clientsTable
|
||
$nameById = [];
|
||
if (is_array($clientsTable)) {
|
||
foreach ($clientsTable as $entry) {
|
||
$cid = $entry['clientId'] ?? '';
|
||
$cname = $entry['userData']['clientName'] ?? null;
|
||
if ($cid !== '' && $cname) {
|
||
$nameById[$cid] = $cname;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (is_array($xrayClients)) {
|
||
foreach ($xrayClients as $xClient) {
|
||
$uuid = $xClient['id'] ?? '';
|
||
if ($uuid === '') continue;
|
||
|
||
// Check if client already exists by public_key (UUID used as identifier)
|
||
$chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND public_key = ?');
|
||
$chk->execute([$serverId, $uuid]);
|
||
if ($chk->fetch()) {
|
||
continue;
|
||
}
|
||
|
||
// Also check by name/email
|
||
$email = $xClient['email'] ?? '';
|
||
if ($email !== '') {
|
||
$chk2 = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND name = ?');
|
||
$chk2->execute([$serverId, $email]);
|
||
if ($chk2->fetch()) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$name = $nameById[$uuid] ?? ($email !== '' ? $email : 'xray-' . substr($uuid, 0, 8));
|
||
|
||
// X-Ray config does not store per-client tunnel IP like WireGuard.
|
||
// Keep client_ip deterministic from config client id (UUID) during restore.
|
||
$clientIp = $uuid;
|
||
|
||
// Generate VLESS config URL for the client
|
||
$host = $serverData['host'] ?? '';
|
||
$realityPub = $details['reality_public_key'] ?? '';
|
||
$shortId = $details['reality_short_id'] ?? '';
|
||
$sni = $details['reality_server_name'] ?? '';
|
||
$flow = $xClient['flow'] ?? 'xtls-rprx-vision';
|
||
|
||
$vlessUrl = sprintf(
|
||
'vless://%s@%s:%d?type=tcp&security=reality&pbk=%s&fp=chrome&sni=%s&sid=%s&spx=%%2F&flow=%s#%s',
|
||
$uuid,
|
||
$host,
|
||
$port,
|
||
urlencode($realityPub),
|
||
urlencode($sni),
|
||
urlencode($shortId),
|
||
urlencode($flow),
|
||
urlencode($name)
|
||
);
|
||
|
||
$ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())');
|
||
$ins->execute([
|
||
$serverId,
|
||
$serverData['user_id'] ?? null,
|
||
$name,
|
||
$clientIp,
|
||
$uuid,
|
||
'',
|
||
'',
|
||
$vlessUrl,
|
||
$pid ?: null,
|
||
'active' // Import as active since they work on the server
|
||
]);
|
||
$imported++;
|
||
Logger::appendInstall($serverId, "Imported X-Ray client: {$name} ({$uuid})");
|
||
}
|
||
}
|
||
|
||
Logger::appendInstall($serverId, "X-Ray restore complete: imported {$imported} clients");
|
||
|
||
return [
|
||
'success' => true,
|
||
'mode' => 'restore',
|
||
'message' => 'Существующая конфигурация X-Ray восстановлена',
|
||
'port' => $port,
|
||
'clients_count' => count($xrayClients),
|
||
'imported_clients' => $imported,
|
||
'reality_public_key' => $details['reality_public_key'] ?? null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Import existing AWG clients from server into database (called during activate with existing config)
|
||
*/
|
||
private static function importExistingAwgClients(VpnServer $server, array $protocol, array $detection): void
|
||
{
|
||
$details = $detection['details'] ?? [];
|
||
$containerName = $details['container_name'] ?? 'amnezia-awg';
|
||
$containerArg = escapeshellarg($containerName);
|
||
$serverId = $server->getId();
|
||
$pdo = DB::conn();
|
||
$serverData = $server->getData();
|
||
$pid = self::resolveProtocolId($protocol);
|
||
|
||
// AWG2: try awg0.conf first (standard), fall back to wg0.conf (legacy)
|
||
$isAwg2 = (stripos($containerName, 'awg2') !== false || ($protocol['slug'] ?? '') === 'awg2');
|
||
$configDir = '/opt/amnezia/awg';
|
||
$configFile = $isAwg2 ? 'awg0.conf' : 'wg0.conf';
|
||
$wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true);
|
||
if ($isAwg2 && (trim($wgConfig) === '' || strpos($wgConfig, '[Interface]') === false)) {
|
||
$configFile = 'wg0.conf';
|
||
$wgConfig = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/{$configFile} 2>/dev/null", true);
|
||
}
|
||
$tableRaw = $server->executeCommand("docker exec -i {$containerArg} cat {$configDir}/clientsTable 2>/dev/null", true);
|
||
$clientsTable = json_decode(trim($tableRaw), true);
|
||
|
||
// Build name lookup
|
||
$nameByPub = [];
|
||
if (is_array($clientsTable)) {
|
||
foreach ($clientsTable as $entry) {
|
||
$cid = $entry['clientId'] ?? '';
|
||
$uname = $entry['userData']['clientName'] ?? null;
|
||
if ($cid !== '' && $uname) {
|
||
$nameByPub[$cid] = $uname;
|
||
}
|
||
}
|
||
}
|
||
|
||
$imported = 0;
|
||
if (trim($wgConfig) !== '') {
|
||
$pattern = '/\[Peer\][^\[]*?PublicKey\s*=\s*(.+?)\s*[\r\n]+[\s\S]*?AllowedIPs\s*=\s*(.+?)(?:\r?\n|$)/';
|
||
if (preg_match_all($pattern, $wgConfig, $matches, PREG_SET_ORDER)) {
|
||
foreach ($matches as $m) {
|
||
$pub = trim($m[1]);
|
||
$allowed = trim($m[2]);
|
||
$clientIp = null;
|
||
foreach (explode(',', $allowed) as $ipSpec) {
|
||
$ipSpec = trim($ipSpec);
|
||
if (preg_match('/^([0-9\.]+)\/32$/', $ipSpec, $mm)) {
|
||
$clientIp = $mm[1];
|
||
break;
|
||
}
|
||
}
|
||
if (!$clientIp) continue;
|
||
|
||
// Check if client already exists
|
||
$chk = $pdo->prepare('SELECT id FROM vpn_clients WHERE server_id = ? AND (client_ip = ? OR public_key = ?)');
|
||
$chk->execute([$serverId, $clientIp, $pub]);
|
||
if ($chk->fetch()) continue;
|
||
|
||
$name = $nameByPub[$pub] ?? ('import-' . str_replace('.', '_', $clientIp));
|
||
$ins = $pdo->prepare('INSERT INTO vpn_clients (server_id, user_id, name, client_ip, public_key, private_key, preshared_key, config, protocol_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())');
|
||
$ins->execute([
|
||
$serverId,
|
||
$serverData['user_id'] ?? null,
|
||
$name,
|
||
$clientIp,
|
||
$pub,
|
||
'',
|
||
$details['preshared_key'] ?? null,
|
||
'',
|
||
$pid ?: null,
|
||
'active' // Import as active since they exist on the server
|
||
]);
|
||
$imported++;
|
||
Logger::appendInstall($serverId, "Imported AWG client: {$name} ({$clientIp})");
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|