diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index a393b45..09d2d5f 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -319,6 +319,9 @@ class InstallProtocolManager '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)) { @@ -575,6 +578,9 @@ class InstallProtocolManager ]; } 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']; @@ -775,10 +781,15 @@ class InstallProtocolManager $pairs = [ 'SERVER_HOST' => $serverData['host'] ?? '', 'SERVER_USER' => $serverData['username'] ?? '', - 'SERVER_CONTAINER' => $serverData['container_name'] ?? ($metadata['container_name'] ?? ''), - 'SERVER_PORT' => isset($serverData['vpn_port']) && (int) $serverData['vpn_port'] > 0 - ? (int) $serverData['vpn_port'] - : (isset($options['server_port']) ? (int) $options['server_port'] : ''), + // 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 @@ -876,7 +887,7 @@ class InstallProtocolManager private static function parseWireGuardConfig(string $config): array { $lines = preg_split('/\r?\n/', $config); - $awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $awgKeys = ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4']; $awgParams = []; $listenPort = null; @@ -1292,6 +1303,127 @@ class InstallProtocolManager } } + private static function runBuiltinAivpnAddClient(VpnServer $server, array $options): array + { + $serverData = $server->getData(); + $containerName = trim((string) ($options['container_name'] ?? ($serverData['container_name'] ?? ''))); + if ($containerName === '' || stripos($containerName, 'aivpn') === false) { + $containerName = 'aivpn-server'; + } + + $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; + } + + $cmdParts = [ + 'docker', + 'exec', + '-i', + escapeshellarg($containerName), + 'aivpn-server', + '--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); + $output = (string) $server->executeCommand($cmd, true); + $parsed = self::parseAivpnAddClientOutput($output); + + if (empty($parsed['connection_uri']) && empty($parsed['connection_key'])) { + $head = substr(str_replace(["\r", "\n"], ' ', trim($output)), 0, 220); + throw new Exception('AIVPN add_client succeeded but no connection key found in output: ' . $head); + } + + $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 runBuiltinXrayAddClient(VpnServer $server, array $options): array { $clientId = $options['client_id'] ?? null; diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 67fc367..e7ef4e9 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -16,6 +16,8 @@ class ServerMonitoring private array $serverData; private array $xrayStatsCache = []; private bool $xrayStatsFetched = false; + private array $aivpnStatsCache = ['by_name' => [], 'by_id' => [], 'by_ip' => []]; + private bool $aivpnStatsFetched = false; /** * Fetch all X-ray user stats in one batch @@ -113,10 +115,20 @@ class ServerMonitoring } } - // Pre-fetch X-ray stats - if (!$this->fetchXrayStats()) { - error_log("Failed to fetch X-ray stats, preventing DB overwrite"); - return []; // Abort if stats collection failed + // Pre-fetch X-ray stats only for Xray servers. + // Otherwise we block AWG/WireGuard stats collection with irrelevant Xray errors. + if ($this->isXrayServer()) { + if (!$this->fetchXrayStats()) { + error_log("Failed to fetch X-ray stats, preventing DB overwrite"); + return []; // Abort only for Xray servers + } + } + + // For AIVPN we best-effort fetch client stats once per cycle. + if ($this->isAivpnServer()) { + if (!$this->fetchAivpnStats()) { + error_log("Failed to fetch AIVPN stats, using DB fallback values"); + } } $clients = VpnClient::listByServer($this->serverData['id']); @@ -271,12 +283,16 @@ class ServerMonitoring // Determine if this client is XRay based on protocol_id $isXrayClient = false; + $protocolSlug = ''; if (!empty($client['protocol_id'])) { $stmtProto = $db->prepare('SELECT slug FROM protocols WHERE id = ?'); $stmtProto->execute([$client['protocol_id']]); $protoData = $stmtProto->fetch(); - if ($protoData && stripos($protoData['slug'], 'xray') !== false) { - $isXrayClient = true; + if ($protoData) { + $protocolSlug = (string) ($protoData['slug'] ?? ''); + if (stripos($protocolSlug, 'xray') !== false) { + $isXrayClient = true; + } } } @@ -285,6 +301,11 @@ class ServerMonitoring $isXrayClient = true; } + $isAivpnClient = ( + stripos($protocolSlug, 'aivpn') !== false || + (!empty($client['config']) && strpos((string) $client['config'], 'aivpn://') === 0) + ); + if ($isXrayClient) { // Retrieve DELTA from cache if ($this->xrayStatsFetched) { @@ -330,6 +351,70 @@ class ServerMonitoring } else { // WireGuard Logic - get bytes and handshake timestamp $publicKey = $client['public_key']; + $isWireguardClient = ( + stripos($protocolSlug, 'awg') !== false || + stripos($protocolSlug, 'wireguard') !== false + ); + + if ($isAivpnClient) { + $aivpn = $this->getAivpnClientStats($client); + if (is_array($aivpn)) { + $stmt = $db->prepare("SELECT bytes_sent, bytes_received, aivpn_raw_bytes_in, aivpn_raw_bytes_out, aivpn_offset_bytes_in, aivpn_offset_bytes_out FROM vpn_clients WHERE id = ?"); + $stmt->execute([$client['id']]); + $currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC); + + $prevSent = (int) ($currentDbStats['bytes_sent'] ?? 0); + $prevReceived = (int) ($currentDbStats['bytes_received'] ?? 0); + $rawInPrev = (int) ($currentDbStats['aivpn_raw_bytes_in'] ?? 0); + $rawOutPrev = (int) ($currentDbStats['aivpn_raw_bytes_out'] ?? 0); + $offsetIn = (int) ($currentDbStats['aivpn_offset_bytes_in'] ?? 0); + $offsetOut = (int) ($currentDbStats['aivpn_offset_bytes_out'] ?? 0); + + $rawInNow = (int) ($aivpn['bytes_in'] ?? 0); + $rawOutNow = (int) ($aivpn['bytes_out'] ?? 0); + + // Detect counter rollover/reset in AIVPN source and preserve cumulative totals. + if ($rawInNow < $rawInPrev) { + $offsetIn = max($offsetIn + $rawInPrev, $prevSent); + } + if ($rawOutNow < $rawOutPrev) { + $offsetOut = max($offsetOut + $rawOutPrev, $prevReceived); + } + + $candidateSent = $offsetIn + $rawInNow; + $candidateReceived = $offsetOut + $rawOutNow; + + // AIVPN stores per-client counters as bytes_in/bytes_out. + // Map to panel semantics: sent=client upload, received=client download. + $bytesSent = max($prevSent, $candidateSent); + $bytesReceived = max($prevReceived, $candidateReceived); + + $stmtAivpn = $db->prepare("UPDATE vpn_clients SET aivpn_raw_bytes_in = ?, aivpn_raw_bytes_out = ?, aivpn_offset_bytes_in = ?, aivpn_offset_bytes_out = ? WHERE id = ?"); + $stmtAivpn->execute([$rawInNow, $rawOutNow, $offsetIn, $offsetOut, $client['id']]); + + $lastHandshake = $aivpn['last_handshake'] ?? null; + if (is_string($lastHandshake) && $lastHandshake !== '') { + $ts = strtotime($lastHandshake); + if ($ts) { + $stmtHs = $db->prepare("UPDATE vpn_clients SET last_handshake = ? WHERE id = ?"); + $stmtHs->execute([date('Y-m-d H:i:s', $ts), $client['id']]); + } + } + } else { + $stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?"); + $stmt->execute([$client['id']]); + $currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC); + $bytesSent = (int) ($currentDbStats['bytes_sent'] ?? 0); + $bytesReceived = (int) ($currentDbStats['bytes_received'] ?? 0); + } + } elseif (empty($publicKey) || !$isWireguardClient) { + // Non-WireGuard protocols without dedicated collectors keep DB values. + $stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?"); + $stmt->execute([$client['id']]); + $currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC); + $bytesSent = (int) ($currentDbStats['bytes_sent'] ?? 0); + $bytesReceived = (int) ($currentDbStats['bytes_received'] ?? 0); + } else { // wg show all dump format (tab-separated): // $1=interface $2=pubkey $3=psk $4=endpoint $5=allowed-ips $6=latest-handshake $7=rx-bytes $8=tx-bytes $9=keepalive // rx-bytes = bytes received by server = client's upload (bytes_sent) @@ -352,6 +437,7 @@ class ServerMonitoring } } } + } } // If we couldn't get stats (and they are 0), check if we have previous stats to avoid zeroing out if API fails? @@ -590,6 +676,140 @@ class ServerMonitoring return $this->getXrayContainerName() !== null; } + /** + * Check if this server is an AIVPN server. + */ + private function isAivpnServer(): bool + { + $containerName = (string) ($this->serverData['container_name'] ?? ''); + $protocol = (string) ($this->serverData['install_protocol'] ?? ''); + return stripos($containerName, 'aivpn') !== false || stripos($protocol, 'aivpn') !== false; + } + + /** + * Fetch AIVPN clients and their stats once per collection cycle. + */ + private function fetchAivpnStats(): bool + { + if ($this->aivpnStatsFetched) { + return true; + } + + $this->aivpnStatsFetched = true; + $this->aivpnStatsCache = ['by_name' => [], 'by_id' => [], 'by_ip' => []]; + + $containerName = trim((string) ($this->serverData['container_name'] ?? '')); + if ($containerName === '' || stripos($containerName, 'aivpn') === false) { + $containerName = 'aivpn-server'; + } + + $jsonRaw = $this->execSSH( + 'docker exec -i ' . escapeshellarg($containerName) . ' cat /etc/aivpn/clients.json 2>/dev/null' + ); + + if (!$jsonRaw || trim($jsonRaw) === '') { + return false; + } + + $data = json_decode($jsonRaw, true); + if (!is_array($data) || !isset($data['clients']) || !is_array($data['clients'])) { + return false; + } + + foreach ($data['clients'] as $entry) { + if (!is_array($entry)) { + continue; + } + + $stats = is_array($entry['stats'] ?? null) ? $entry['stats'] : []; + $record = [ + 'id' => (string) ($entry['id'] ?? ''), + 'name' => (string) ($entry['name'] ?? ''), + 'vpn_ip' => (string) ($entry['vpn_ip'] ?? ''), + 'bytes_in' => (int) ($stats['bytes_in'] ?? 0), + 'bytes_out' => (int) ($stats['bytes_out'] ?? 0), + 'last_handshake' => isset($stats['last_handshake']) ? (string) $stats['last_handshake'] : null, + ]; + + if ($record['name'] !== '') { + $this->aivpnStatsCache['by_name'][strtolower($record['name'])] = $record; + } + if ($record['id'] !== '') { + $this->aivpnStatsCache['by_id'][$record['id']] = $record; + } + if ($record['vpn_ip'] !== '') { + $this->aivpnStatsCache['by_ip'][$record['vpn_ip']] = $record; + } + } + + return true; + } + + private function getAivpnClientStats(array $client): ?array + { + if (!$this->aivpnStatsFetched && !$this->fetchAivpnStats()) { + return null; + } + + $name = trim((string) ($client['name'] ?? '')); + if ($name !== '') { + $nameKey = strtolower($name); + if (isset($this->aivpnStatsCache['by_name'][$nameKey])) { + return $this->aivpnStatsCache['by_name'][$nameKey]; + } + } + + $clientIp = trim((string) ($client['client_ip'] ?? '')); + if ($clientIp !== '' && isset($this->aivpnStatsCache['by_ip'][$clientIp])) { + return $this->aivpnStatsCache['by_ip'][$clientIp]; + } + + $cfgIp = $this->extractAivpnIpFromConfig((string) ($client['config'] ?? '')); + if ($cfgIp !== '' && isset($this->aivpnStatsCache['by_ip'][$cfgIp])) { + return $this->aivpnStatsCache['by_ip'][$cfgIp]; + } + + return null; + } + + private function extractAivpnIpFromConfig(string $config): string + { + if (stripos($config, 'aivpn://') !== 0) { + return ''; + } + + $payload = substr($config, strlen('aivpn://')); + if ($payload === '') { + return ''; + } + + $decoded = base64_decode(strtr($payload, '-_', '+/'), true); + if ($decoded === false) { + $padLen = strlen($payload) % 4; + $normalized = $payload; + if ($padLen > 0) { + $normalized .= str_repeat('=', 4 - $padLen); + } + $decoded = base64_decode(strtr($normalized, '-_', '+/'), true); + } + + if ($decoded === false) { + return ''; + } + + $data = json_decode($decoded, true); + if (!is_array($data)) { + return ''; + } + + $ip = trim((string) ($data['i'] ?? '')); + if ($ip !== '' && preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip)) { + return $ip; + } + + return ''; + } + /** * Enforce single IP per user for Xray connections * If a user is connected from multiple IPs, block all but the first one diff --git a/inc/VpnClient.php b/inc/VpnClient.php index 6dae8a2..08cf493 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -126,7 +126,7 @@ class VpnClient } // Add AWG parameters (use UPPERCASE keys internal logic) - foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($cleanAwgParams[$key])) { $vars[$key] = $cleanAwgParams[$key]; } else { @@ -137,6 +137,8 @@ class VpnClient 'JMAX' => 200, 'S1' => 50, 'S2' => 100, + 'S3' => 20, + 'S4' => 10, 'H1' => 1, 'H2' => 2, 'H3' => 3, @@ -213,7 +215,7 @@ class VpnClient foreach ($extras as $k => $v) { if (is_scalar($v)) { // Preserve uppercase for AWG obfuscation parameters - if (in_array($k, ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'], true)) { + if (in_array($k, ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'], true)) { $vars[$k] = (string) $v; } else { $vars[strtolower($k)] = (string) $v; @@ -379,6 +381,10 @@ class VpnClient } } } + if ($slug === 'aivpn') { + // Canonical connection key should come from AIVPN --add-client output. + // We keep fallback generation later only if add_client flow didn't provide a key. + } $pass = null; $pwdCmd = isset($protoRow['password_command']) ? trim((string) $protoRow['password_command']) : ''; if ($pwdCmd !== '') { @@ -418,13 +424,59 @@ class VpnClient // For xray-vless it uses builtin fallback in runScript. try { require_once __DIR__ . '/InstallProtocolManager.php'; - InstallProtocolManager::addClient($server, $protoRow, $vars); + $addClientResult = InstallProtocolManager::addClient($server, $protoRow, $vars); + if (is_array($addClientResult)) { + foreach ($addClientResult as $rk => $rv) { + if (!is_scalar($rv)) { + continue; + } + $key = (string) $rk; + $value = trim((string) $rv); + if ($value === '') { + continue; + } + $vars[$key] = $value; + $vars[strtolower($key)] = $value; + } + + if ($slug === 'aivpn') { + if (empty($vars['connection_key']) && !empty($vars['connection_uri']) && stripos((string) $vars['connection_uri'], 'aivpn://') === 0) { + $vars['connection_key'] = substr((string) $vars['connection_uri'], strlen('aivpn://')); + } + if (!empty($vars['client_ip']) && preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', (string) $vars['client_ip'])) { + $clientIP = (string) $vars['client_ip']; + $vars['client_ip'] = $clientIP; + } + } + } } catch (Exception $e) { error_log("Failed to add client to server: " . $e->getMessage()); throw $e; } } + if ($slug === 'aivpn' && empty($vars['connection_key'])) { + try { + $rawKey = trim((string) $server->executeCommand('cat /etc/aivpn/server.key 2>/dev/null', true)); + if ($rawKey !== '' && !empty($vars['client_ip']) && !empty($vars['server_host']) && !empty($vars['server_port'])) { + $payload = [ + 'i' => (string) $vars['client_ip'], + 'k' => $rawKey, + 'p' => '', + 's' => (string) $vars['server_host'] . ':' . (string) $vars['server_port'], + ]; + $json = (string) json_encode($payload, JSON_UNESCAPED_SLASHES); + $vars['connection_key'] = rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + } catch (Exception $e) { + // Keep empty: final template output will expose a missing key. + } + } + + if ($slug === 'aivpn' && !empty($vars['connection_key'])) { + $vars['connection_key'] = self::normalizeAivpnConnectionKey((string) $vars['connection_key']); + } + $config = $protoRow ? ProtocolService::generateProtocolOutput($protoRow, $vars) : ''; // Prepare last_config_json for QR code generation if config is JSON (XRay) @@ -467,6 +519,46 @@ class VpnClient return (int) $pdo->lastInsertId(); } + private static function normalizeAivpnConnectionKey(string $key): string + { + $key = trim($key); + if ($key === '') { + return $key; + } + + $decoded = base64_decode(strtr($key, '-_', '+/'), true); + if ($decoded === false) { + $padLen = strlen($key) % 4; + $normalized = $key; + if ($padLen > 0) { + $normalized .= str_repeat('=', 4 - $padLen); + } + $decoded = base64_decode(strtr($normalized, '-_', '+/'), true); + } + + if ($decoded === false) { + return $key; + } + + $data = json_decode($decoded, true); + if (!is_array($data) || empty($data['s']) || !is_string($data['s'])) { + return $key; + } + + $endpoint = trim($data['s']); + $endpoint = preg_replace('#^https?://#i', '', $endpoint); + $endpoint = preg_replace('#/.*$#', '', $endpoint ?? ''); + + if ($endpoint !== '' && preg_match('/^(.+?)(?::\d+){2,}$/', $endpoint, $m) && preg_match('/:(\d+)$/', $endpoint, $pm)) { + $endpoint = trim((string) $m[1]) . ':' . (string) $pm[1]; + $data['s'] = $endpoint; + $json = (string) json_encode($data, JSON_UNESCAPED_SLASHES); + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + return $key; + } + public static function listByServerAndProtocol(int $serverId, int $protocolId): array { $pdo = DB::conn(); @@ -681,7 +773,7 @@ class VpnClient $awgParams = []; $awgLinesCmd = sprintf( - "docker exec %s sh -c \"grep -E '^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)[[:space:]]*=' %s 2>/dev/null || true\"", + "docker exec %s sh -c \"grep -E '^[[:space:]]*(Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4)[[:space:]]*=' %s 2>/dev/null || true\"", escapeshellarg($containerName), escapeshellarg($confPath) ); @@ -692,7 +784,7 @@ class VpnClient if ($line === '') { continue; } - if (preg_match('/^(Jc|Jmin|Jmax|S1|S2|H1|H2|H3|H4)\s*=\s*(\d+)\s*$/i', $line, $m)) { + if (preg_match('/^(Jc|Jmin|Jmax|S1|S2|S3|S4|H1|H2|H3|H4)\s*=\s*(\d+)\s*$/i', $line, $m)) { $k = strtoupper($m[1]); $awgParams[$k] = (int) $m[2]; } @@ -803,7 +895,7 @@ class VpnClient // Legacy attempt: some builds print jc/jmin/... in `wg show` output. $wgShowCmd = "docker exec $containerName wg show wg0 2>/dev/null"; $wgOutput = (string) $server->executeCommand($wgShowCmd, true); - $paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 'h1', 'h2', 'h3', 'h4']; + $paramNames = ['jc', 'jmin', 'jmax', 's1', 's2', 's3', 's4', 'h1', 'h2', 'h3', 'h4']; foreach ($paramNames as $param) { if (preg_match('/^\s*' . preg_quote($param, '/') . ':\s*(\d+)/mi', $wgOutput, $matches)) { $awgParams[strtoupper($param)] = (int) $matches[1]; @@ -862,7 +954,7 @@ class VpnClient $config .= "DNS = 1.1.1.1, 1.0.0.1\n"; // Add AWG parameters - foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($awgParams[$key])) { $config .= "{$key} = {$awgParams[$key]}\n"; continue; @@ -1370,7 +1462,7 @@ class VpnClient // If AWG params are missing (common after reinstall), fetch them directly from wg0.conf // to avoid falling back to template defaults that will not match the server. if (in_array($slug, ['amnezia-wg-advanced', 'awg2'], true)) { - $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']; + $needKeys = ['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4']; $missing = false; foreach ($needKeys as $k) { if (!isset($awgParams[$k])) { @@ -1438,7 +1530,7 @@ class VpnClient 'dns_servers' => (string) ($serverData['dns_servers'] ?? '1.1.1.1, 1.0.0.1'), ]; - foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4'] as $key) { + foreach (['JC', 'JMIN', 'JMAX', 'S1', 'S2', 'S3', 'S4', 'H1', 'H2', 'H3', 'H4'] as $key) { if (isset($awgParams[$key])) { $vars[$key] = $awgParams[$key]; } @@ -1574,7 +1666,7 @@ class VpnClient try { // Get previous stats for speed calculation $pdo = DB::conn(); - $stmtPrev = $pdo->prepare('SELECT bytes_sent, bytes_received, last_sync_at, last_handshake FROM vpn_clients WHERE id = ?'); + $stmtPrev = $pdo->prepare('SELECT bytes_sent, bytes_received, last_sync_at, last_handshake, aivpn_raw_bytes_in, aivpn_raw_bytes_out, aivpn_offset_bytes_in, aivpn_offset_bytes_out FROM vpn_clients WHERE id = ?'); $stmtPrev->execute([$this->clientId]); $prev = $stmtPrev->fetch(); @@ -1582,20 +1674,31 @@ class VpnClient $prevReceived = (int) ($prev['bytes_received'] ?? 0); $prevSyncAt = $prev['last_sync_at'] ? strtotime($prev['last_sync_at']) : 0; $prevHandshake = $prev['last_handshake'] ? strtotime($prev['last_handshake']) : 0; + $aivpnRawInPrev = (int) ($prev['aivpn_raw_bytes_in'] ?? 0); + $aivpnRawOutPrev = (int) ($prev['aivpn_raw_bytes_out'] ?? 0); + $aivpnOffsetIn = (int) ($prev['aivpn_offset_bytes_in'] ?? 0); + $aivpnOffsetOut = (int) ($prev['aivpn_offset_bytes_out'] ?? 0); // XRay stats logic $stats = []; // Determine protocol by client's protocol_id $isXray = false; + $isAivpn = false; $xrayContainerName = 'amnezia-xray'; // Default XRay container name if (!empty($this->data['protocol_id'])) { $stmtProto = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?'); $stmtProto->execute([$this->data['protocol_id']]); $protoData = $stmtProto->fetch(); - if ($protoData && stripos($protoData['slug'], 'xray') !== false) { - $isXray = true; + if ($protoData) { + $slug = (string) ($protoData['slug'] ?? ''); + if (stripos($slug, 'xray') !== false) { + $isXray = true; + } + if (stripos($slug, 'aivpn') !== false) { + $isAivpn = true; + } } } @@ -1605,8 +1708,12 @@ class VpnClient if (strpos($containerName, 'xray') !== false) { $isXray = true; $xrayContainerName = $containerName; + } elseif (strpos($containerName, 'aivpn') !== false) { + $isAivpn = true; } elseif (!empty($this->data['config']) && strpos($this->data['config'], 'vless://') !== false) { $isXray = true; + } elseif (!empty($this->data['config']) && strpos($this->data['config'], 'aivpn://') === 0) { + $isAivpn = true; } } @@ -1648,6 +1755,28 @@ class VpnClient } } + } elseif ($isAivpn) { + $stats = self::getAivpnStatsFromServer($serverData, $this->data); + if (!empty($stats)) { + $rawInNow = (int) ($stats['bytes_sent'] ?? 0); + $rawOutNow = (int) ($stats['bytes_received'] ?? 0); + + if ($rawInNow < $aivpnRawInPrev) { + $aivpnOffsetIn = max($aivpnOffsetIn + $aivpnRawInPrev, $prevSent); + } + if ($rawOutNow < $aivpnRawOutPrev) { + $aivpnOffsetOut = max($aivpnOffsetOut + $aivpnRawOutPrev, $prevReceived); + } + + $candidateSent = $aivpnOffsetIn + $rawInNow; + $candidateReceived = $aivpnOffsetOut + $rawOutNow; + $stats['bytes_sent'] = max($prevSent, $candidateSent); + $stats['bytes_received'] = max($prevReceived, $candidateReceived); + + if (empty($stats['last_handshake']) || (int) $stats['last_handshake'] <= 0) { + $stats['last_handshake'] = $prevHandshake; + } + } } if (empty($stats)) { @@ -1681,16 +1810,43 @@ class VpnClient } } - $stmt = $pdo->prepare(' - UPDATE vpn_clients - SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, last_sync_at = NOW() - WHERE id = ? - '); + $isAivpnPersist = $isAivpn && !empty($stats); + if ($isAivpnPersist) { + $stmt = $pdo->prepare(' + UPDATE vpn_clients + SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, + aivpn_raw_bytes_in = ?, aivpn_raw_bytes_out = ?, aivpn_offset_bytes_in = ?, aivpn_offset_bytes_out = ?, + last_sync_at = NOW() + WHERE id = ? + '); + } else { + $stmt = $pdo->prepare(' + UPDATE vpn_clients + SET bytes_sent = ?, bytes_received = ?, last_handshake = ?, current_speed = ?, speed_up = ?, speed_down = ?, last_sync_at = NOW() + WHERE id = ? + '); + } $lastHandshake = $stats['last_handshake'] > 0 ? date('Y-m-d H:i:s', $stats['last_handshake']) : null; + if ($isAivpnPersist) { + return $stmt->execute([ + $stats['bytes_sent'], + $stats['bytes_received'], + $lastHandshake, + $currentSpeed, + $speedUp, + $speedDown, + (int) ($stats['bytes_sent_raw'] ?? 0), + (int) ($stats['bytes_received_raw'] ?? 0), + $aivpnOffsetIn, + $aivpnOffsetOut, + $this->clientId + ]); + } + return $stmt->execute([ $stats['bytes_sent'], $stats['bytes_received'], @@ -1706,6 +1862,110 @@ class VpnClient } } + private static function getAivpnStatsFromServer(array $serverData, array $clientData): array + { + $stats = [ + 'bytes_sent' => 0, + 'bytes_received' => 0, + 'bytes_sent_raw' => 0, + 'bytes_received_raw' => 0, + 'last_handshake' => 0, + ]; + + $containerName = (string) ($serverData['container_name'] ?? ''); + if ($containerName === '' || stripos($containerName, 'aivpn') === false) { + $containerName = 'aivpn-server'; + } + + $cmd = sprintf('docker exec -i %s cat /etc/aivpn/clients.json 2>/dev/null', escapeshellarg($containerName)); + $output = self::executeServerCommand($serverData, $cmd, true); + if (trim((string) $output) === '') { + return $stats; + } + + $data = json_decode((string) $output, true); + if (!is_array($data) || !isset($data['clients']) || !is_array($data['clients'])) { + return $stats; + } + + $name = strtolower(trim((string) ($clientData['name'] ?? ''))); + $clientIp = trim((string) ($clientData['client_ip'] ?? '')); + $cfgIp = self::extractAivpnIpFromConfig((string) ($clientData['config'] ?? '')); + + $match = null; + foreach ($data['clients'] as $entry) { + if (!is_array($entry)) { + continue; + } + $entryName = strtolower(trim((string) ($entry['name'] ?? ''))); + $entryIp = trim((string) ($entry['vpn_ip'] ?? '')); + if ($name !== '' && $entryName === $name) { + $match = $entry; + break; + } + if ($clientIp !== '' && $entryIp === $clientIp) { + $match = $entry; + break; + } + if ($cfgIp !== '' && $entryIp === $cfgIp) { + $match = $entry; + break; + } + } + + if (!is_array($match)) { + return $stats; + } + + $s = is_array($match['stats'] ?? null) ? $match['stats'] : []; + $rawIn = (int) ($s['bytes_in'] ?? 0); + $rawOut = (int) ($s['bytes_out'] ?? 0); + $stats['bytes_sent_raw'] = $rawIn; + $stats['bytes_received_raw'] = $rawOut; + $stats['bytes_sent'] = $rawIn; + $stats['bytes_received'] = $rawOut; + + if (!empty($s['last_handshake']) && is_string($s['last_handshake'])) { + $ts = strtotime($s['last_handshake']); + if ($ts !== false) { + $stats['last_handshake'] = (int) $ts; + } + } + + return $stats; + } + + private static function extractAivpnIpFromConfig(string $config): string + { + if (stripos($config, 'aivpn://') !== 0) { + return ''; + } + + $payload = substr($config, strlen('aivpn://')); + if ($payload === '') { + return ''; + } + + $b64 = strtr($payload, '-_', '+/'); + $padLen = strlen($b64) % 4; + if ($padLen > 0) { + $b64 .= str_repeat('=', 4 - $padLen); + } + + $decoded = base64_decode($b64, true); + if ($decoded === false) { + return ''; + } + + $data = json_decode($decoded, true); + if (!is_array($data)) { + return ''; + } + + $ip = trim((string) ($data['i'] ?? '')); + return preg_match('/^\d{1,3}(?:\.\d{1,3}){3}$/', $ip) ? $ip : ''; + } + /** * Get client statistics from server */ diff --git a/inc/VpnServer.php b/inc/VpnServer.php index ababa46..aee6752 100644 --- a/inc/VpnServer.php +++ b/inc/VpnServer.php @@ -706,6 +706,19 @@ BASH; $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -A FORWARD -i wg0 -o eth0 -s 10.8.1.0/24 -j ACCEPT 2>/dev/null || true'", true); $this->executeCommand("docker exec -i {$containerName} sh -c 'iptables -t nat -A POSTROUTING -s 10.8.1.0/24 -o eth0 -j MASQUERADE 2>/dev/null || true'", true); + // Ensure host-level forwarding/NAT for AWG subnet as well (required on some Docker host setups). + $vpnSubnet = (string) ($this->data['vpn_subnet'] ?? '10.8.1.0/24'); + $vpnSubnetEsc = escapeshellarg($vpnSubnet); + $hostNatCmd = "bash -lc 'IFACE=\\$(ip route | awk \"{if (\\$1==\\\"default\\\") {print \\$5; exit}}\"); " . + "iptables -t nat -C POSTROUTING -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j MASQUERADE 2>/dev/null || " . + "iptables -t nat -I POSTROUTING 1 -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j MASQUERADE; " . + "iptables -C FORWARD -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j ACCEPT 2>/dev/null || " . + "iptables -I FORWARD 1 -s " . $vpnSubnetEsc . " -o \\\"\\$IFACE\\\" -j ACCEPT; " . + "iptables -C FORWARD -d " . $vpnSubnetEsc . " -m conntrack --ctstate RELATED,ESTABLISHED -i \\\"\\$IFACE\\\" -j ACCEPT 2>/dev/null || " . + "iptables -I FORWARD 1 -d " . $vpnSubnetEsc . " -m conntrack --ctstate RELATED,ESTABLISHED -i \\\"\\$IFACE\\\" -j ACCEPT; " . + "sysctl -w net.ipv4.ip_forward=1 >/dev/null'"; + $this->executeCommand($hostNatCmd, true); + sleep(2); return [ diff --git a/migrations/061_fix_client_connection_instructions_translation.sql b/migrations/061_fix_client_connection_instructions_translation.sql new file mode 100644 index 0000000..07424ea --- /dev/null +++ b/migrations/061_fix_client_connection_instructions_translation.sql @@ -0,0 +1,11 @@ +-- Ensure clients.connection_instructions exists in all locales used by UI. +-- Without this key, client view heading may be missing or fallback text can appear inconsistent. + +INSERT INTO translations (locale, category, key_name, translation) VALUES +('en', 'clients', 'connection_instructions', 'Connection Instructions'), +('ru', 'clients', 'connection_instructions', 'Инструкции по подключению'), +('es', 'clients', 'connection_instructions', 'Instrucciones de conexión'), +('de', 'clients', 'connection_instructions', 'Verbindungsanweisungen'), +('fr', 'clients', 'connection_instructions', 'Instructions de connexion'), +('zh', 'clients', 'connection_instructions', '连接说明') +ON DUPLICATE KEY UPDATE translation = VALUES(translation); diff --git a/migrations/062_add_aivpn_counter_offsets.sql b/migrations/062_add_aivpn_counter_offsets.sql new file mode 100644 index 0000000..69d1083 --- /dev/null +++ b/migrations/062_add_aivpn_counter_offsets.sql @@ -0,0 +1,7 @@ +-- Add persistent AIVPN raw/offset counters for monotonic traffic totals across server restarts. + +ALTER TABLE vpn_clients + ADD COLUMN aivpn_raw_bytes_in BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER bytes_received, + ADD COLUMN aivpn_raw_bytes_out BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_raw_bytes_in, + ADD COLUMN aivpn_offset_bytes_in BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_raw_bytes_out, + ADD COLUMN aivpn_offset_bytes_out BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER aivpn_offset_bytes_in; diff --git a/scripts/remote_reset_and_reinstall_all_protocols.sh b/scripts/remote_reset_and_reinstall_all_protocols.sh new file mode 100755 index 0000000..12241f3 --- /dev/null +++ b/scripts/remote_reset_and_reinstall_all_protocols.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +PANEL_URL="http://localhost:8082" +EMAIL="admin@amnez.ia" +PASSWORD="admin123" +SERVER_ID="1" +REMOTE_HOST="217.26.25.6" +REMOTE_USER="root" +REMOTE_PASS='1Fr045jZbtF!' + +# protocol IDs in this workspace +AWG2_ID="11" +AIVPN_ID="13" +MTPROXY_ID="12" + +echo "== auth ==" +TOKEN=$(curl -sS -X POST "$PANEL_URL/api/auth/token" \ + -d "email=$EMAIL&password=$PASSWORD" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') + +echo "== remote full docker cleanup ==" +sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" 'bash -s' <<'EOSSH' +set -euo pipefail + +# Stop and remove all containers if any +if [ -n "$(docker ps -aq 2>/dev/null || true)" ]; then + docker rm -f $(docker ps -aq) >/dev/null 2>&1 || true +fi + +# Full cleanup of images/volumes/networks/build cache +if command -v docker >/dev/null 2>&1; then + docker system prune -af --volumes >/dev/null 2>&1 || true + docker builder prune -af >/dev/null 2>&1 || true +fi + +# Remove protocol dirs to force fresh bootstrap +rm -rf /opt/amnezia /etc/aivpn /etc/amnezia /etc/mtproxy 2>/dev/null || true +mkdir -p /opt/amnezia /etc/aivpn /etc/amnezia /etc/mtproxy + +echo "remote cleanup done" +EOSSH + +echo "== install awg2 ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$AWG2_ID}" | tee /tmp/install_awg2_after_remote_reset.json + +echo +echo "== install aivpn ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$AIVPN_ID}" | tee /tmp/install_aivpn_after_remote_reset.json + +echo +echo "== install mtproxy ==" +curl -sS -X POST "$PANEL_URL/api/servers/$SERVER_ID/protocols/install" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data "{\"protocol_id\":$MTPROXY_ID}" | tee /tmp/install_mtproxy_after_remote_reset.json + +echo +echo "== verify containers on remote ==" +sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ + "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +echo +echo "done" diff --git a/templates/clients/view.twig b/templates/clients/view.twig index ec35a65..3f1edc9 100644 --- a/templates/clients/view.twig +++ b/templates/clients/view.twig @@ -155,7 +155,7 @@ {% if protocol_output and client.show_text_content %}

{{ t('clients.connection_instructions') }}

-
{{ protocol_output }}
+
{{ protocol_output }}
{% endif %}